From a8fbb882f28cd5033f3f5ca8853ee6c49d396e4c Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 20 Nov 2023 18:14:02 +0100 Subject: [PATCH] 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 --- changelog.d/1453.feature | 1 + .../features/messages/impl/MessagesView.kt | 13 +- .../impl/mentions/MentionSuggestion.kt | 26 ++++ .../mentions/MentionSuggestionsPickerView.kt | 31 +++-- .../mentions/MentionSuggestionsProcessor.kt | 18 ++- .../messagecomposer/MessageComposerEvents.kt | 2 + .../MessageComposerPresenter.kt | 37 ++++-- .../messagecomposer/MessageComposerState.kt | 5 +- .../MessageComposerStateProvider.kt | 5 +- .../messagecomposer/MessageComposerView.kt | 6 +- .../MessageComposerPresenterTest.kt | 31 +++-- .../android/libraries/core/data/FilterUpTo.kt | 39 ++++++ .../designsystem/theme/ColorAliases.kt | 30 +++++ .../matrix/api/permalink/PermalinkParser.kt | 18 +-- libraries/textcomposer/impl/build.gradle.kts | 3 + .../libraries/textcomposer/TextComposer.kt | 30 +++++ .../textcomposer/mentions/MentionSpan.kt | 42 +++++++ .../mentions/MentionSpanProvider.kt | 116 ++++++++++++++++++ .../impl/mentions/MentionSpanProviderTest.kt | 67 ++++++++++ ...ionSpan-Day-18_18_null,NEXUS_5,1.0,en].png | 3 + ...nSpan-Night-18_19_null,NEXUS_5,1.0,en].png | 3 + 21 files changed, 465 insertions(+), 61 deletions(-) create mode 100644 changelog.d/1453.feature create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt create mode 100644 libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt create mode 100644 libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt create mode 100644 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 create mode 100644 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 diff --git a/changelog.d/1453.feature b/changelog.d/1453.feature new file mode 100644 index 0000000000..86cc90ad74 --- /dev/null +++ b/changelog.d/1453.feature @@ -0,0 +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 d07afd43b5..c00fbe09aa 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 @@ -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( @@ -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, ) @@ -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( 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 0000000000..b2977bd508 --- /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 6b937a872e..779398b020 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, - onSuggestionSelected: (RoomMemberSuggestion) -> Unit, + memberSuggestions: ImmutableList, + 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 1f4e2b5376..d2c3e6e518 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 { + ): List { 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?, currentUserId: UserId, canSendRoomMention: Boolean, - ): List { + ): List { 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 97c2e7015d..c8ce20b6b9 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 c4b631e4df..3a952efb69 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 { private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) @@ -173,7 +175,7 @@ class MessageComposerPresenter @Inject constructor( } } - val memberSuggestions = remember { mutableStateListOf() } + val memberSuggestions = remember { mutableStateListOf() } 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 6a9d963d18..09eb51477f 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, + val memberSuggestions: ImmutableList, + 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 ac936b2118..a542cb772d 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 = persistentListOf(), + memberSuggestions: ImmutableList = 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 b988d545a6..28e40a910f 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/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 f05c209085..ef58792552 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/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 0000000000..d3e2cc3e78 --- /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 Iterable.filterUpTo(count: Int, predicate: (T) -> Boolean): List { + val result = mutableListOf() + 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 e85abb396b..c860239d6a 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 ba9cdc1e80..4a89d05276 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 { - 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 db97a96787..99f910d317 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 93e552768a..75dbc6c1c0 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 0000000000..609a050abf --- /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 0000000000..77674e1254 --- /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/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 0000000000..748a06bb2c --- /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 0000000000..87bfdadf60 --- /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 0000000000..2b93b20917 --- /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