Skip to content

Commit a8fbb88

Browse files
jmartinespElementBot
andauthored
Integrate mentions in the composer (#1799)
* Integrate mentions in the composer: - Add `MentionSpanProvider`. - Add custom colors needed for mentions. - Use the span provider to render mentions in the composer. - Allow selecting users from the mentions suggestions to insert a mention. --------- Co-authored-by: ElementBot <[email protected]>
1 parent 004804a commit a8fbb88

File tree

21 files changed

+465
-61
lines changed

21 files changed

+465
-61
lines changed

changelog.d/1453.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for typing mentions in the message composer.

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
4141
import androidx.compose.runtime.Composable
4242
import androidx.compose.runtime.DisposableEffect
4343
import androidx.compose.runtime.LaunchedEffect
44+
import androidx.compose.runtime.mutableIntStateOf
4445
import androidx.compose.runtime.remember
4546
import androidx.compose.ui.Alignment
4647
import androidx.compose.ui.Modifier
@@ -354,12 +355,12 @@ private fun MessagesViewContent(
354355

355356
// This key is used to force the sheet to be remeasured when the content changes.
356357
// Any state change that should trigger a height size should be added to the list of remembered values here.
357-
val sheetResizeContentKey = remember(
358-
state.composerState.mode.relatedEventId,
358+
val sheetResizeContentKey = remember { mutableIntStateOf(0) }
359+
LaunchedEffect(
359360
state.composerState.richTextEditorState.lineCount,
360-
state.composerState.memberSuggestions.size
361+
state.composerState.showTextFormatting,
361362
) {
362-
Random.nextInt()
363+
sheetResizeContentKey.intValue = Random.nextInt()
363364
}
364365

365366
ExpandableBottomSheetScaffold(
@@ -396,7 +397,7 @@ private fun MessagesViewContent(
396397
state = state,
397398
)
398399
},
399-
sheetContentKey = sheetResizeContentKey,
400+
sheetContentKey = sheetResizeContentKey.intValue,
400401
sheetTonalElevation = 0.dp,
401402
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
402403
)
@@ -425,7 +426,7 @@ private fun MessagesViewComposerBottomSheetContents(
425426
roomAvatarData = state.roomAvatar.dataOrNull(),
426427
memberSuggestions = state.composerState.memberSuggestions,
427428
onSuggestionSelected = {
428-
// TODO pass the selected suggestion to the RTE so it can be inserted as a pill
429+
state.composerState.eventSink(MessageComposerEvents.InsertMention(it))
429430
}
430431
)
431432
MessageComposerView(
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.messages.impl.mentions
18+
19+
import androidx.compose.runtime.Immutable
20+
import io.element.android.libraries.matrix.api.room.RoomMember
21+
22+
@Immutable
23+
sealed interface MentionSuggestion {
24+
data object Room : MentionSuggestion
25+
data class Member(val roomMember: RoomMember) : MentionSuggestion
26+
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import androidx.compose.ui.res.stringResource
3131
import androidx.compose.ui.text.style.TextOverflow
3232
import androidx.compose.ui.unit.dp
3333
import io.element.android.features.messages.impl.R
34-
import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
3534
import io.element.android.libraries.designsystem.components.avatar.Avatar
3635
import io.element.android.libraries.designsystem.components.avatar.AvatarData
3736
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -52,8 +51,8 @@ fun MentionSuggestionsPickerView(
5251
roomId: RoomId,
5352
roomName: String?,
5453
roomAvatarData: AvatarData?,
55-
memberSuggestions: ImmutableList<RoomMemberSuggestion>,
56-
onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
54+
memberSuggestions: ImmutableList<MentionSuggestion>,
55+
onSuggestionSelected: (MentionSuggestion) -> Unit,
5756
modifier: Modifier = Modifier,
5857
) {
5958
LazyColumn(
@@ -63,8 +62,8 @@ fun MentionSuggestionsPickerView(
6362
memberSuggestions,
6463
key = { suggestion ->
6564
when (suggestion) {
66-
is RoomMemberSuggestion.Room -> "@room"
67-
is RoomMemberSuggestion.Member -> suggestion.roomMember.userId.value
65+
is MentionSuggestion.Room -> "@room"
66+
is MentionSuggestion.Member -> suggestion.roomMember.userId.value
6867
}
6968
}
7069
) {
@@ -85,32 +84,32 @@ fun MentionSuggestionsPickerView(
8584

8685
@Composable
8786
private fun RoomMemberSuggestionItemView(
88-
memberSuggestion: RoomMemberSuggestion,
87+
memberSuggestion: MentionSuggestion,
8988
roomId: String,
9089
roomName: String?,
9190
roomAvatar: AvatarData?,
92-
onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
91+
onSuggestionSelected: (MentionSuggestion) -> Unit,
9392
modifier: Modifier = Modifier,
9493
) {
9594
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
9695
val avatarSize = AvatarSize.TimelineRoom
9796
val avatarData = when (memberSuggestion) {
98-
is RoomMemberSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
99-
is RoomMemberSuggestion.Member -> AvatarData(
97+
is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
98+
is MentionSuggestion.Member -> AvatarData(
10099
memberSuggestion.roomMember.userId.value,
101100
memberSuggestion.roomMember.displayName,
102101
memberSuggestion.roomMember.avatarUrl,
103102
avatarSize,
104103
)
105104
}
106105
val title = when (memberSuggestion) {
107-
is RoomMemberSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
108-
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.displayName
106+
is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
107+
is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
109108
}
110109

111110
val subtitle = when (memberSuggestion) {
112-
is RoomMemberSuggestion.Room -> "@room"
113-
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.userId.value
111+
is MentionSuggestion.Room -> "@room"
112+
is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
114113
}
115114

116115
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
@@ -159,9 +158,9 @@ internal fun MentionSuggestionsPickerView_Preview() {
159158
roomName = "Room",
160159
roomAvatarData = null,
161160
memberSuggestions = persistentListOf(
162-
RoomMemberSuggestion.Room,
163-
RoomMemberSuggestion.Member(roomMember),
164-
RoomMemberSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
161+
MentionSuggestion.Room,
162+
MentionSuggestion.Member(roomMember),
163+
MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
165164
),
166165
onSuggestionSelected = {}
167166
)

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
package io.element.android.features.messages.impl.mentions
1818

19-
import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
19+
import io.element.android.libraries.core.data.filterUpTo
2020
import io.element.android.libraries.matrix.api.core.UserId
2121
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
2222
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -46,10 +46,8 @@ object MentionSuggestionsProcessor {
4646
roomMembersState: MatrixRoomMembersState,
4747
currentUserId: UserId,
4848
canSendRoomMention: suspend () -> Boolean,
49-
): List<RoomMemberSuggestion> {
49+
): List<MentionSuggestion> {
5050
val members = roomMembersState.roomMembers()
51-
// Take the first MAX_BATCH_ITEMS only
52-
?.take(MAX_BATCH_ITEMS)
5351
return when {
5452
members.isNullOrEmpty() || suggestion == null -> {
5553
// Clear suggestions
@@ -61,7 +59,7 @@ object MentionSuggestionsProcessor {
6159
// Replace suggestions
6260
val matchingMembers = getMemberSuggestions(
6361
query = suggestion.text,
64-
roomMembers = roomMembersState.roomMembers(),
62+
roomMembers = members,
6563
currentUserId = currentUserId,
6664
canSendRoomMention = canSendRoomMention()
6765
)
@@ -81,7 +79,7 @@ object MentionSuggestionsProcessor {
8179
roomMembers: List<RoomMember>?,
8280
currentUserId: UserId,
8381
canSendRoomMention: Boolean,
84-
): List<RoomMemberSuggestion> {
82+
): List<MentionSuggestion> {
8583
return if (roomMembers.isNullOrEmpty()) {
8684
emptyList()
8785
} else {
@@ -95,14 +93,14 @@ object MentionSuggestionsProcessor {
9593
}
9694

9795
val matchingMembers = roomMembers
98-
// Search only in joined members, exclude the current user
99-
.filter { member ->
96+
// Search only in joined members, up to MAX_BATCH_ITEMS, exclude the current user
97+
.filterUpTo(MAX_BATCH_ITEMS) { member ->
10098
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
10199
}
102-
.map(RoomMemberSuggestion::Member)
100+
.map(MentionSuggestion::Member)
103101

104102
if ("room".contains(query) && canSendRoomMention) {
105-
listOf(RoomMemberSuggestion.Room) + matchingMembers
103+
listOf(MentionSuggestion.Room) + matchingMembers
106104
} else {
107105
matchingMembers
108106
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package io.element.android.features.messages.impl.messagecomposer
1818

1919
import androidx.compose.runtime.Immutable
20+
import io.element.android.features.messages.impl.mentions.MentionSuggestion
2021
import io.element.android.libraries.textcomposer.model.Message
2122
import io.element.android.libraries.textcomposer.model.MessageComposerMode
2223
import io.element.android.libraries.textcomposer.model.Suggestion
@@ -41,4 +42,5 @@ sealed interface MessageComposerEvents {
4142
data object CancelSendAttachment : MessageComposerEvents
4243
data class Error(val error: Throwable) : MessageComposerEvents
4344
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
45+
data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents
4446
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import android.Manifest
2020
import android.annotation.SuppressLint
2121
import android.net.Uri
2222
import androidx.compose.runtime.Composable
23-
import androidx.compose.runtime.Immutable
2423
import androidx.compose.runtime.LaunchedEffect
2524
import androidx.compose.runtime.MutableState
2625
import androidx.compose.runtime.getValue
@@ -36,6 +35,7 @@ import im.vector.app.features.analytics.plan.Composer
3635
import io.element.android.features.messages.impl.attachments.Attachment
3736
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
3837
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
38+
import io.element.android.features.messages.impl.mentions.MentionSuggestion
3939
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
4040
import io.element.android.libraries.architecture.Presenter
4141
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -45,8 +45,8 @@ import io.element.android.libraries.di.SingleIn
4545
import io.element.android.libraries.featureflag.api.FeatureFlagService
4646
import io.element.android.libraries.featureflag.api.FeatureFlags
4747
import io.element.android.libraries.matrix.api.core.ProgressCallback
48+
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
4849
import io.element.android.libraries.matrix.api.room.MatrixRoom
49-
import io.element.android.libraries.matrix.api.room.RoomMember
5050
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
5151
import io.element.android.libraries.mediapickers.api.PickerProvider
5252
import io.element.android.libraries.mediaupload.api.MediaSender
@@ -67,6 +67,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
6767
import kotlinx.coroutines.flow.collect
6868
import kotlinx.coroutines.flow.combine
6969
import kotlinx.coroutines.flow.debounce
70+
import kotlinx.coroutines.flow.filter
71+
import kotlinx.coroutines.flow.merge
7072
import kotlinx.coroutines.isActive
7173
import kotlinx.coroutines.launch
7274
import javax.inject.Inject
@@ -87,7 +89,7 @@ class MessageComposerPresenter @Inject constructor(
8789
private val messageComposerContext: MessageComposerContextImpl,
8890
private val richTextEditorStateFactory: RichTextEditorStateFactory,
8991
private val currentSessionIdHolder: CurrentSessionIdHolder,
90-
permissionsPresenterFactory: PermissionsPresenter.Factory
92+
permissionsPresenterFactory: PermissionsPresenter.Factory,
9193
) : Presenter<MessageComposerState> {
9294

9395
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@@ -173,7 +175,7 @@ class MessageComposerPresenter @Inject constructor(
173175
}
174176
}
175177

176-
val memberSuggestions = remember { mutableStateListOf<RoomMemberSuggestion>() }
178+
val memberSuggestions = remember { mutableStateListOf<MentionSuggestion>() }
177179
LaunchedEffect(isMentionsEnabled) {
178180
if (!isMentionsEnabled) return@LaunchedEffect
179181
val currentUserId = currentSessionIdHolder.current
@@ -184,8 +186,11 @@ class MessageComposerPresenter @Inject constructor(
184186
return !roomIsDm && userCanSendAtRoom
185187
}
186188

187-
suggestionSearchTrigger
188-
.debounce(0.5.seconds)
189+
// This will trigger a search immediately when `@` is typed
190+
val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() }
191+
// This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work
192+
val mentionCompletionTrigger = suggestionSearchTrigger.filter { !it?.text.isNullOrEmpty() }.debounce(0.3.seconds)
193+
merge(mentionStartTrigger, mentionCompletionTrigger)
189194
.combine(room.membersStateFlow) { suggestion, roomMembersState ->
190195
memberSuggestions.clear()
191196
val result = MentionSuggestionsProcessor.process(
@@ -284,6 +289,20 @@ class MessageComposerPresenter @Inject constructor(
284289
is MessageComposerEvents.SuggestionReceived -> {
285290
suggestionSearchTrigger.value = event.suggestion
286291
}
292+
is MessageComposerEvents.InsertMention -> {
293+
localCoroutineScope.launch {
294+
when (val mention = event.mention) {
295+
is MentionSuggestion.Room -> {
296+
richTextEditorState.insertAtRoomMentionAtSuggestion()
297+
}
298+
is MentionSuggestion.Member -> {
299+
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
300+
val link = PermalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
301+
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
302+
}
303+
}
304+
}
305+
}
287306
}
288307
}
289308

@@ -297,6 +316,7 @@ class MessageComposerPresenter @Inject constructor(
297316
canCreatePoll = canCreatePoll.value,
298317
attachmentsState = attachmentsState.value,
299318
memberSuggestions = memberSuggestions.toPersistentList(),
319+
currentUserId = currentSessionIdHolder.current,
300320
eventSink = { handleEvents(it) }
301321
)
302322
}
@@ -410,8 +430,3 @@ class MessageComposerPresenter @Inject constructor(
410430
}
411431
}
412432

413-
@Immutable
414-
sealed interface RoomMemberSuggestion {
415-
data object Room : RoomMemberSuggestion
416-
data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
417-
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package io.element.android.features.messages.impl.messagecomposer
1919
import androidx.compose.runtime.Immutable
2020
import androidx.compose.runtime.Stable
2121
import io.element.android.features.messages.impl.attachments.Attachment
22+
import io.element.android.features.messages.impl.mentions.MentionSuggestion
23+
import io.element.android.libraries.matrix.api.core.UserId
2224
import io.element.android.libraries.textcomposer.model.MessageComposerMode
2325
import io.element.android.wysiwyg.compose.RichTextEditorState
2426
import kotlinx.collections.immutable.ImmutableList
@@ -33,7 +35,8 @@ data class MessageComposerState(
3335
val canShareLocation: Boolean,
3436
val canCreatePoll: Boolean,
3537
val attachmentsState: AttachmentsState,
36-
val memberSuggestions: ImmutableList<RoomMemberSuggestion>,
38+
val memberSuggestions: ImmutableList<MentionSuggestion>,
39+
val currentUserId: UserId,
3740
val eventSink: (MessageComposerEvents) -> Unit,
3841
) {
3942
val hasFocus: Boolean = richTextEditorState.hasFocus

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package io.element.android.features.messages.impl.messagecomposer
1818

1919
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
20+
import io.element.android.features.messages.impl.mentions.MentionSuggestion
21+
import io.element.android.libraries.matrix.api.core.UserId
2022
import io.element.android.libraries.textcomposer.model.MessageComposerMode
2123
import io.element.android.wysiwyg.compose.RichTextEditorState
2224
import kotlinx.collections.immutable.ImmutableList
@@ -38,7 +40,7 @@ fun aMessageComposerState(
3840
canShareLocation: Boolean = true,
3941
canCreatePoll: Boolean = true,
4042
attachmentsState: AttachmentsState = AttachmentsState.None,
41-
memberSuggestions: ImmutableList<RoomMemberSuggestion> = persistentListOf(),
43+
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
4244
) = MessageComposerState(
4345
richTextEditorState = composerState,
4446
isFullScreen = isFullScreen,
@@ -49,5 +51,6 @@ fun aMessageComposerState(
4951
canCreatePoll = canCreatePoll,
5052
attachmentsState = attachmentsState,
5153
memberSuggestions = memberSuggestions,
54+
currentUserId = UserId("@alice:localhost"),
5255
eventSink = {},
5356
)

0 commit comments

Comments
 (0)