Integrate mentions in the composer

## 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|
|-|-|
|||

## Tests

- 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

- [ ] 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

Mentions: @room mention pill

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

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)
+            }
+        })
+    }
+}

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_USER_ID_2.value}")
         }
     }

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


Show full history if key backup is enabled.

<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Type of change

- [ ] Feature
- [x] Bugfix
- [ ] Technical
- [ ] Other :

## Content

<!-- Describe shortly what has been changed -->
If KeyBackup is enabled, do not show the no history banner in encrypted room.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #1768

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->

## Tests

<!-- Explain how you tested your development -->

- Log in to an account with a e2e room RoomA with messages.
- Verify the session
- Open RoomA and see that the banner is displayed
- From the setting, enter the recovery key to unlock the chat backup
- open the RoomA
- observe that the history of the room is visible (and decrypted if the keys are in the backup)

Before this PR, the banner was always displayed, event if the history was present and decrypted.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## 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
- [ ] 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
- [ ] Pull request is based on the develop branch
- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] 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)
- [ ] You've made a self review of your PR

KeyBackup: do not show the "Message history" banner if key backup is set up.
Show all the messages instead, even if they cannot be decrypted.

diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index 99dd0af8fd7..035add9ab2c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -72,6 +72,7 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeout
+import org.matrix.rustcomponents.sdk.BackupState
 import org.matrix.rustcomponents.sdk.Client
 import org.matrix.rustcomponents.sdk.ClientDelegate
 import org.matrix.rustcomponents.sdk.NotificationProcessSetup
@@ -200,6 +201,7 @@ class RustMatrixClient constructor(
         cachedPairOfRoom?.let { (roomListItem, fullRoom) ->
             RustMatrixRoom(
                 sessionId = sessionId,
+                isKeyBackupEnabled = client.encryption().backupState() == BackupState.ENABLED,
                 roomListItem = roomListItem,
                 innerRoom = fullRoom,
                 roomNotificationSettingsService = notificationSettingsService,
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 6e47e90dcd8..ab7317590e7 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
@@ -86,6 +86,7 @@ import java.io.File
 @OptIn(ExperimentalCoroutinesApi::class)
 class RustMatrixRoom(
     override val sessionId: SessionId,
+    isKeyBackupEnabled: Boolean,
     private val roomListItem: RoomListItem,
     private val innerRoom: Room,
     private val roomNotificationSettingsService: RustNotificationSettingsService,
@@ -126,6 +127,7 @@ class RustMatrixRoom(
     override val roomNotificationSettingsStateFlow: StateFlow<MatrixRoomNotificationSettingsState> = _roomNotificationSettingsStateFlow

     override val timeline = RustMatrixTimeline(
+        isKeyBackupEnabled = isKeyBackupEnabled,
         matrixRoom = this,
         innerRoom = innerRoom,
         roomCoroutineScope = roomCoroutineScope,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
index 74ad10ad26a..53ae41d887d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
@@ -57,6 +57,7 @@ private const val INITIAL_MAX_SIZE = 50

 class RustMatrixTimeline(
     roomCoroutineScope: CoroutineScope,
+    isKeyBackupEnabled: Boolean,
     private val matrixRoom: MatrixRoom,
     private val innerRoom: Room,
     private val dispatcher: CoroutineDispatcher,
@@ -77,6 +78,7 @@ class RustMatrixTimeline(
     private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
         lastLoginTimestamp = lastLoginTimestamp,
         isRoomEncrypted = matrixRoom.isEncrypted,
+        isKeyBackupEnabled = isKeyBackupEnabled,
         paginationStateFlow = _paginationState,
         dispatcher = dispatcher,
     )
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt
index b273bef21bc..60de981780a 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt
@@ -30,12 +30,13 @@ class TimelineEncryptedHistoryPostProcessor(
     private val dispatcher: CoroutineDispatcher,
     private val lastLoginTimestamp: Date?,
     private val isRoomEncrypted: Boolean,
+    private val isKeyBackupEnabled: Boolean,
     private val paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState>,
 ) {

     suspend fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> = withContext(dispatcher) {
         Timber.d("Process on Thread=${Thread.currentThread()}")
-        if (!isRoomEncrypted || lastLoginTimestamp == null) return@withContext items
+        if (!isRoomEncrypted || isKeyBackupEnabled || lastLoginTimestamp == null) return@withContext items

         val filteredItems = replaceWithEncryptionHistoryBannerIfNeeded(items)
         // Disable back pagination

diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt
index 63920cd6cef..d3af9a56707 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt
@@ -41,6 +41,15 @@ class TimelineEncryptedHistoryPostProcessorTest {
         assertThat(processor.process(items)).isSameInstanceAs(items)
     }

+    @Test
+    fun `given an encrypted room, and key backup enabled, nothing is done`() = runTest {
+        val processor = createPostProcessor(isKeyBackupEnabled = true)
+        val items = listOf(
+            MatrixTimelineItem.Event(0L, anEventTimelineItem())
+        )
+        assertThat(processor.process(items)).isSameInstanceAs(items)
+    }
+
     @Test
     fun `given a null lastLoginTimestamp, nothing is done`() = runTest {
         val processor = createPostProcessor(lastLoginTimestamp = null)
@@ -108,11 +117,13 @@ class TimelineEncryptedHistoryPostProcessorTest {
     private fun TestScope.createPostProcessor(
         lastLoginTimestamp: Date? = defaultLastLoginTimestamp,
         isRoomEncrypted: Boolean = true,
+        isKeyBackupEnabled: Boolean = false,
         paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState> =
             MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false))
     ) = TimelineEncryptedHistoryPostProcessor(
         lastLoginTimestamp = lastLoginTimestamp,
         isRoomEncrypted = isRoomEncrypted,
+        isKeyBackupEnabled = isKeyBackupEnabled,
         paginationStateFlow = paginationStateFlow,
         dispatcher = StandardTestDispatcher(testScheduler)
     )


Feature/bma/encrypted messages

<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Type of change

- [ ] Feature
- [ ] Bugfix
- [ ] Technical
- [x] Other : wording

## Content

<!-- Describe shortly what has been changed -->
Wording change

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #1754

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->

## Tests

<!-- Explain how you tested your development -->

- Step 1
- Step 2
- Step ...

## Tested devices

- [ ] Physical
- [ ] Emulator
- OS version(s):

## 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
- [ ] 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
- [ ] Pull request is based on the develop branch
- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md#changelog
- [ ] 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)
- [ ] You've made a self review of your PR

KeyBackup: use common_waiting_for_decryption_key for message which cannot be decrypted

diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
index 462edb1e606..23c698cfc61 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt
@@ -20,8 +20,8 @@ import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.designsystem.utils.CommonDrawables
 import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
 import io.element.android.libraries.ui.strings.CommonStrings
@@ -33,7 +33,7 @@ fun TimelineItemEncryptedView(
     modifier: Modifier = Modifier
 ) {
     TimelineItemInformativeView(
-        text = stringResource(id = CommonStrings.common_decryption_error),
+        text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
         iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
         iconResourceId = CommonDrawables.ic_september_decryption_error,
         extraPadding = extraPadding,
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
index 96626e8633e..aad89c5c2c9 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt
@@ -81,7 +81,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
                 content.body
             }
             is UnableToDecryptContent -> {
-                val message = sp.getString(CommonStrings.common_decryption_error)
+                val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
                 if (!isDmRoom) {
                     prefix(message, senderDisplayName)
                 } else {

diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
index e9d91fbb8c3..f7b98626e66 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt
@@ -113,7 +113,7 @@ class DefaultRoomLastMessageFormatterTest {
     @Test
     @Config(qualifiers = "en")
     fun `Unable to decrypt content`() {
-        val expected = "Decryption error"
+        val expected = "Waiting for this message"
         val senderName = "Someone"
         sequenceOf(false, true).forEach { isDm ->
             val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown))
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Day-29_29_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Day-29_29_null,NEXUS_5,1.0,en].png
index 59c709b5064..be47b705f51 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Day-29_29_null,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Day-29_29_null,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:f9eafafb5d374c341df0e4206652cbc14bf6b5d665c3a1ba89b6d029eb26b1d4
-size 8228
+oid sha256:78884f6cdde3d493293186d078360d51d894e42f134b44729a9d44b7d9e1b260
+size 9822
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Night-29_30_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Night-29_30_null,NEXUS_5,1.0,en].png
index 85ededab65f..63f4ed66bcf 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Night-29_30_null,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemEncryptedView_null_TimelineItemEncryptedView-Night-29_30_null,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:13c80439b33af959e0b1d1056120915a37ad8fd7ac1f40a84db0c9a38cbd7a15
-size 8123
+oid sha256:a3ee25c2e1aed91646e07465eabaa7419019f376b5870af6653016dc2403df0d
+size 9701
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Day-8_8_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Day-8_8_null_1,NEXUS_5,1.0,en].png
index defd4f56651..ea99de42488 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Day-8_8_null_1,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Day-8_8_null_1,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:b6abcfd0b6fac2fc054876463d444afe0a0ef0755cf2452aec7a75490c8ea9d8
-size 64674
+oid sha256:56d2a4fe8ceadfb945622519910e13fbe3422561745efc4fe0362eb599fcb2ac
+size 73633
diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Night-8_9_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Night-8_9_null_1,NEXUS_5,1.0,en].png
index 20866c94ab6..62c10e1bab6 100644
--- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Night-8_9_null_1,NEXUS_5,1.0,en].png
+++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline_TimelineView_null_TimelineView-Night-8_9_null_1,NEXUS_5,1.0,en].png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:d0b2da862f047951267beddb0cccaa6ac374ab1f78c770e91451199f02da0eb8
-size 62209
+oid sha256:55fbd1123d267fea510345fc272a1b1a9bda7a8222eebf7e9d96a8202f071813
+size 70610


<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Type of change

- [ ] Feature
- [x] Bugfix
- [ ] Technical
- [ ] Other :

## Content

- Made `SnackbarDispatcher.snackbarMessage` a Flow backed by a queue.
- Added `SnackbarMessage.isDisplayed` property to check if the message is already being displayed.
- Clears the `SnackbarDispatcher` queue too when the coroutine displaying the current message is cancelled.

## Motivation and context

Fixes #928.

## Tests

<!-- Explain how you tested your development -->
In an unverified session:
- Go to Settings -> Complete verification.
- Finish the verification.
- See the snackbar being displayed.
- Go back to the room list. No snackbar should be displayed here.

An alternative test is to go to your home screen and then back to the app.

## 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
- [ ] 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
- [ ] 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

"Verification complete" snackbar is shown too much
1. Login to an account and verify the session.
2. The snackbar is displayed, this is fine
3. Pause and resume the app
4. The Snackbar is displayed again.

3 and 4 can be repeated indefinitely.

Kill the app and restarting it fixes the issue.

A tentative fix was done in #811, but this was not enough apparently.

diff --git a/changelog.d/928.bugfix b/changelog.d/928.bugfix
new file mode 100644
index 00000000000..98a4cd34e06
--- /dev/null
+++ b/changelog.d/928.bugfix
@@ -0,0 +1,1 @@
+Make sure Snackbars are only displayed once.
diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts
index 35b7a65cb4c..f6eacf87462 100644
--- a/libraries/designsystem/build.gradle.kts
+++ b/libraries/designsystem/build.gradle.kts
@@ -43,5 +43,11 @@ android {

         ksp(libs.showkase.processor)
         kspTest(libs.showkase.processor)
+
+        testImplementation(libs.test.junit)
+        testImplementation(libs.coroutines.test)
+        testImplementation(libs.molecule.runtime)
+        testImplementation(libs.test.truth)
+        testImplementation(libs.test.turbine)
     }
 }
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
index c689d7e5479..9458bf748ac 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt
@@ -34,33 +34,40 @@ import androidx.compose.ui.unit.dp
 import io.element.android.libraries.designsystem.components.button.ButtonVisuals
 import io.element.android.libraries.designsystem.theme.components.IconSource
 import io.element.android.libraries.designsystem.theme.components.Snackbar
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
+import java.util.concurrent.atomic.AtomicBoolean

 /**
  * A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
  */
 class SnackbarDispatcher {
-    private val mutex = Mutex()
-
-    private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null)
-    val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow()
+    private val queueMutex = Mutex()
+    private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
+    val snackbarMessage: Flow<SnackbarMessage?> = flow {
+        while (currentCoroutineContext().isActive) {
+            queueMutex.lock()
+            emit(snackBarMessageQueue.firstOrNull())
+        }
+    }

     suspend fun post(message: SnackbarMessage) {
-        mutex.withLock {
-            _snackbarMessage.update { message }
+        if (snackBarMessageQueue.isEmpty()) {
+            snackBarMessageQueue.add(message)
+            if (queueMutex.isLocked) queueMutex.unlock()
+        } else {
+            snackBarMessageQueue.add(message)
         }
     }

-    suspend fun clear() {
-        mutex.withLock {
-            _snackbarMessage.update { null }
+    fun clear() {
+        if (snackBarMessageQueue.isNotEmpty()) {
+            snackBarMessageQueue.removeFirstOrNull()
+            if (queueMutex.isLocked) queueMutex.unlock()
         }
     }
 }
@@ -87,31 +94,51 @@ fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) {
     }
 }

+/**
+ * Helper method to display a [SnackbarMessage] in a [SnackbarHostState] handling cancellations.
+ */
 @Composable
 fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
     val snackbarHostState = remember { SnackbarHostState() }
     val snackbarMessageText = snackbarMessage?.let {
         stringResource(id = snackbarMessage.messageResId)
-    }
+    } ?: return snackbarHostState
+
     val dispatcher = LocalSnackbarDispatcher.current
-    LaunchedEffect(snackbarMessage) {
-        if (snackbarMessageText == null) return@LaunchedEffect
-        launch {
-            snackbarHostState.showSnackbar(
-                message = snackbarMessageText,
-                duration = snackbarMessage.duration,
-            )
-            if (isActive) {
+    LaunchedEffect(snackbarMessageText) {
+        // If the message wasn't already displayed, do it now, and mark it as displayed
+        // This will prevent the message from appearing in any other active SnackbarHosts
+        if (snackbarMessage.isDisplayed.getAndSet(true) == false) {
+            try {
+                snackbarHostState.showSnackbar(
+                    message = snackbarMessageText,
+                    duration = snackbarMessage.duration,
+                )
+                // The snackbar item was displayed and dismissed, clear its message
+                dispatcher.clear()
+            } catch (e: CancellationException) {
+                // The snackbar was being displayed when the coroutine was cancelled,
+                // so we need to clear its message
                 dispatcher.clear()
+                throw e
             }
         }
     }
     return snackbarHostState
 }

+/**
+ * A message to be displayed in a [Snackbar].
+ * @param messageResId The message to be displayed.
+ * @param duration The duration of the message. The default value is [SnackbarDuration.Short].
+ * @param actionResId The action text to be displayed. The default value is `null`.
+ * @param isDisplayed Used to track if the current message is already displayed or not.
+ * @param action The action to be performed when the action is clicked.
+ */
 data class SnackbarMessage(
     @StringRes val messageResId: Int,
     val duration: SnackbarDuration = SnackbarDuration.Short,
     @StringRes val actionResId: Int? = null,
+    val isDisplayed: AtomicBoolean = AtomicBoolean(false),
     val action: () -> Unit = {},
 )

diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt
new file mode 100644
index 00000000000..3eb644d8008
--- /dev/null
+++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/SnackbarDispatcherTests.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.designsystem.utils
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SnackbarDispatcherTests {
+
+    @Test
+    fun `given an empty queue the flow emits a null item`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            assertThat(awaitItem()).isNull()
+        }
+    }
+
+    @Test
+    fun `given an empty queue calling clear does nothing`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            assertThat(awaitItem()).isNull()
+            snackbarDispatcher.clear()
+            expectNoEvents()
+        }
+    }
+
+    @Test
+    fun `given a non-empty queue the flow emits an item`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            snackbarDispatcher.post(SnackbarMessage(0))
+            val result = expectMostRecentItem()
+            assertThat(result).isNotNull()
+        }
+    }
+
+    @Test
+    fun `given a call to clear, the current message is cleared`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            snackbarDispatcher.post(SnackbarMessage(0))
+            val item = expectMostRecentItem()
+            assertThat(item).isNotNull()
+            snackbarDispatcher.clear()
+            assertThat(awaitItem()).isNull()
+        }
+    }
+
+    @Test
+    fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
+        val snackbarDispatcher = SnackbarDispatcher()
+        snackbarDispatcher.snackbarMessage.test {
+            val messageA = SnackbarMessage(0)
+            val messageB = SnackbarMessage(1)
+
+            // Send message A - it is the most recent item
+            snackbarDispatcher.post(messageA)
+            assertThat(expectMostRecentItem()).isEqualTo(messageA)
+
+            // Send message B - message A is still the most recent item
+            snackbarDispatcher.post(messageB)
+            expectNoEvents()
+
+            // Clear the last message - message B is now the most recent item
+            snackbarDispatcher.clear()
+            assertThat(expectMostRecentItem()).isEqualTo(messageB)
+
+            // Clear again - the queue is empty
+            snackbarDispatcher.clear()
+            assertThat(awaitItem()).isNull()
+        }
+    }
+
+}