Skip to content

Commit

Permalink
Integrate mentions in the composer (#1799)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
jmartinesp and ElementBot authored Nov 20, 2023
1 parent 004804a commit a8fbb88
Show file tree
Hide file tree
Showing 21 changed files with 465 additions and 61 deletions.
1 change: 1 addition & 0 deletions changelog.d/1453.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for typing mentions in the message composer.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -354,12 +355,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(
Expand Down Expand Up @@ -396,7 +397,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,
)
Expand Down Expand Up @@ -425,7 +426,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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
}
}
) {
Expand All @@ -85,32 +84,32 @@ 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,
avatarSize,
)
}
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))
Expand Down Expand Up @@ -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 = {}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -61,7 +59,7 @@ object MentionSuggestionsProcessor {
// Replace suggestions
val matchingMembers = getMemberSuggestions(
query = suggestion.text,
roomMembers = roomMembersState.roomMembers(),
roomMembers = members,
currentUserId = currentUserId,
canSendRoomMention = canSendRoomMention()
)
Expand All @@ -81,7 +79,7 @@ object MentionSuggestionsProcessor {
roomMembers: List<RoomMember>?,
currentUserId: UserId,
canSendRoomMention: Boolean,
): List<RoomMemberSuggestion> {
): List<MentionSuggestion> {
return if (roomMembers.isNullOrEmpty()) {
emptyList()
} else {
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
}
}
}
}
}
}

Expand All @@ -297,6 +316,7 @@ class MessageComposerPresenter @Inject constructor(
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(),
currentUserId = currentSessionIdHolder.current,
eventSink = { handleEvents(it) }
)
}
Expand Down Expand Up @@ -410,8 +430,3 @@ class MessageComposerPresenter @Inject constructor(
}
}

@Immutable
sealed interface RoomMemberSuggestion {
data object Room : RoomMemberSuggestion
data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -49,5 +51,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions,
currentUserId = UserId("@alice:localhost"),
eventSink = {},
)
Loading

0 comments on commit a8fbb88

Please sign in to comment.