Browse Source

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 <benoitm+elementbot@element.io>
pull/1844/head
Jorge Martin Espinosa 10 months ago committed by GitHub
parent
commit
a8fbb882f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      changelog.d/1453.feature
  2. 13
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
  3. 26
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt
  4. 31
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
  5. 18
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
  6. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
  7. 37
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
  8. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
  9. 5
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
  10. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
  11. 31
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
  12. 39
      libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt
  13. 30
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
  14. 18
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt
  15. 3
      libraries/textcomposer/impl/build.gradle.kts
  16. 30
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
  17. 42
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt
  18. 116
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
  19. 67
      libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
  20. BIN
      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
  21. BIN
      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

1
changelog.d/1453.feature

@ -0,0 +1 @@
Add support for typing mentions in the message composer.

13
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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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. // 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. // Any state change that should trigger a height size should be added to the list of remembered values here.
val sheetResizeContentKey = remember( val sheetResizeContentKey = remember { mutableIntStateOf(0) }
state.composerState.mode.relatedEventId, LaunchedEffect(
state.composerState.richTextEditorState.lineCount, state.composerState.richTextEditorState.lineCount,
state.composerState.memberSuggestions.size state.composerState.showTextFormatting,
) { ) {
Random.nextInt() sheetResizeContentKey.intValue = Random.nextInt()
} }
ExpandableBottomSheetScaffold( ExpandableBottomSheetScaffold(
@ -396,7 +397,7 @@ private fun MessagesViewContent(
state = state, state = state,
) )
}, },
sheetContentKey = sheetResizeContentKey, sheetContentKey = sheetResizeContentKey.intValue,
sheetTonalElevation = 0.dp, sheetTonalElevation = 0.dp,
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp, sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
) )
@ -425,7 +426,7 @@ private fun MessagesViewComposerBottomSheetContents(
roomAvatarData = state.roomAvatar.dataOrNull(), roomAvatarData = state.roomAvatar.dataOrNull(),
memberSuggestions = state.composerState.memberSuggestions, memberSuggestions = state.composerState.memberSuggestions,
onSuggestionSelected = { onSuggestionSelected = {
// TODO pass the selected suggestion to the RTE so it can be inserted as a pill state.composerState.eventSink(MessageComposerEvents.InsertMention(it))
} }
) )
MessageComposerView( MessageComposerView(

26
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
}

31
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R 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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -52,8 +51,8 @@ fun MentionSuggestionsPickerView(
roomId: RoomId, roomId: RoomId,
roomName: String?, roomName: String?,
roomAvatarData: AvatarData?, roomAvatarData: AvatarData?,
memberSuggestions: ImmutableList<RoomMemberSuggestion>, memberSuggestions: ImmutableList<MentionSuggestion>,
onSuggestionSelected: (RoomMemberSuggestion) -> Unit, onSuggestionSelected: (MentionSuggestion) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@ -63,8 +62,8 @@ fun MentionSuggestionsPickerView(
memberSuggestions, memberSuggestions,
key = { suggestion -> key = { suggestion ->
when (suggestion) { when (suggestion) {
is RoomMemberSuggestion.Room -> "@room" is MentionSuggestion.Room -> "@room"
is RoomMemberSuggestion.Member -> suggestion.roomMember.userId.value is MentionSuggestion.Member -> suggestion.roomMember.userId.value
} }
} }
) { ) {
@ -85,18 +84,18 @@ fun MentionSuggestionsPickerView(
@Composable @Composable
private fun RoomMemberSuggestionItemView( private fun RoomMemberSuggestionItemView(
memberSuggestion: RoomMemberSuggestion, memberSuggestion: MentionSuggestion,
roomId: String, roomId: String,
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData?,
onSuggestionSelected: (RoomMemberSuggestion) -> Unit, onSuggestionSelected: (MentionSuggestion) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val avatarSize = AvatarSize.TimelineRoom val avatarSize = AvatarSize.TimelineRoom
val avatarData = when (memberSuggestion) { val avatarData = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is RoomMemberSuggestion.Member -> AvatarData( is MentionSuggestion.Member -> AvatarData(
memberSuggestion.roomMember.userId.value, memberSuggestion.roomMember.userId.value,
memberSuggestion.roomMember.displayName, memberSuggestion.roomMember.displayName,
memberSuggestion.roomMember.avatarUrl, memberSuggestion.roomMember.avatarUrl,
@ -104,13 +103,13 @@ private fun RoomMemberSuggestionItemView(
) )
} }
val title = when (memberSuggestion) { val title = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title) is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.displayName is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
} }
val subtitle = when (memberSuggestion) { val subtitle = when (memberSuggestion) {
is RoomMemberSuggestion.Room -> "@room" is MentionSuggestion.Room -> "@room"
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.userId.value is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
} }
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) 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", roomName = "Room",
roomAvatarData = null, roomAvatarData = null,
memberSuggestions = persistentListOf( memberSuggestions = persistentListOf(
RoomMemberSuggestion.Room, MentionSuggestion.Room,
RoomMemberSuggestion.Member(roomMember), MentionSuggestion.Member(roomMember),
RoomMemberSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
), ),
onSuggestionSelected = {} onSuggestionSelected = {}
) )

18
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 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.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -46,10 +46,8 @@ object MentionSuggestionsProcessor {
roomMembersState: MatrixRoomMembersState, roomMembersState: MatrixRoomMembersState,
currentUserId: UserId, currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean, canSendRoomMention: suspend () -> Boolean,
): List<RoomMemberSuggestion> { ): List<MentionSuggestion> {
val members = roomMembersState.roomMembers() val members = roomMembersState.roomMembers()
// Take the first MAX_BATCH_ITEMS only
?.take(MAX_BATCH_ITEMS)
return when { return when {
members.isNullOrEmpty() || suggestion == null -> { members.isNullOrEmpty() || suggestion == null -> {
// Clear suggestions // Clear suggestions
@ -61,7 +59,7 @@ object MentionSuggestionsProcessor {
// Replace suggestions // Replace suggestions
val matchingMembers = getMemberSuggestions( val matchingMembers = getMemberSuggestions(
query = suggestion.text, query = suggestion.text,
roomMembers = roomMembersState.roomMembers(), roomMembers = members,
currentUserId = currentUserId, currentUserId = currentUserId,
canSendRoomMention = canSendRoomMention() canSendRoomMention = canSendRoomMention()
) )
@ -81,7 +79,7 @@ object MentionSuggestionsProcessor {
roomMembers: List<RoomMember>?, roomMembers: List<RoomMember>?,
currentUserId: UserId, currentUserId: UserId,
canSendRoomMention: Boolean, canSendRoomMention: Boolean,
): List<RoomMemberSuggestion> { ): List<MentionSuggestion> {
return if (roomMembers.isNullOrEmpty()) { return if (roomMembers.isNullOrEmpty()) {
emptyList() emptyList()
} else { } else {
@ -95,14 +93,14 @@ object MentionSuggestionsProcessor {
} }
val matchingMembers = roomMembers val matchingMembers = roomMembers
// Search only in joined members, exclude the current user // Search only in joined members, up to MAX_BATCH_ITEMS, exclude the current user
.filter { member -> .filterUpTo(MAX_BATCH_ITEMS) { member ->
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query) isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
} }
.map(RoomMemberSuggestion::Member) .map(MentionSuggestion::Member)
if ("room".contains(query) && canSendRoomMention) { if ("room".contains(query) && canSendRoomMention) {
listOf(RoomMemberSuggestion.Room) + matchingMembers listOf(MentionSuggestion.Room) + matchingMembers
} else { } else {
matchingMembers matchingMembers
} }

2
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 package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable 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.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.Suggestion
@ -41,4 +42,5 @@ sealed interface MessageComposerEvents {
data object CancelSendAttachment : MessageComposerEvents data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents data class Error(val error: Throwable) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents
} }

37
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.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue 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.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError 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.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.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender 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.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -87,7 +89,7 @@ class MessageComposerPresenter @Inject constructor(
private val messageComposerContext: MessageComposerContextImpl, private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder, private val currentSessionIdHolder: CurrentSessionIdHolder,
permissionsPresenterFactory: PermissionsPresenter.Factory permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) 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) { LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = currentSessionIdHolder.current val currentUserId = currentSessionIdHolder.current
@ -184,8 +186,11 @@ class MessageComposerPresenter @Inject constructor(
return !roomIsDm && userCanSendAtRoom return !roomIsDm && userCanSendAtRoom
} }
suggestionSearchTrigger // This will trigger a search immediately when `@` is typed
.debounce(0.5.seconds) 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 -> .combine(room.membersStateFlow) { suggestion, roomMembersState ->
memberSuggestions.clear() memberSuggestions.clear()
val result = MentionSuggestionsProcessor.process( val result = MentionSuggestionsProcessor.process(
@ -284,6 +289,20 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.SuggestionReceived -> { is MessageComposerEvents.SuggestionReceived -> {
suggestionSearchTrigger.value = event.suggestion 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, canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(), memberSuggestions = memberSuggestions.toPersistentList(),
currentUserId = currentSessionIdHolder.current,
eventSink = { handleEvents(it) } 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
}

5
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.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment 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.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -33,7 +35,8 @@ data class MessageComposerState(
val canShareLocation: Boolean, val canShareLocation: Boolean,
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<RoomMemberSuggestion>, val memberSuggestions: ImmutableList<MentionSuggestion>,
val currentUserId: UserId,
val eventSink: (MessageComposerEvents) -> Unit, val eventSink: (MessageComposerEvents) -> Unit,
) { ) {
val hasFocus: Boolean = richTextEditorState.hasFocus val hasFocus: Boolean = richTextEditorState.hasFocus

5
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 package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -38,7 +40,7 @@ fun aMessageComposerState(
canShareLocation: Boolean = true, canShareLocation: Boolean = true,
canCreatePoll: Boolean = true, canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None, attachmentsState: AttachmentsState = AttachmentsState.None,
memberSuggestions: ImmutableList<RoomMemberSuggestion> = persistentListOf(), memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
) = MessageComposerState( ) = MessageComposerState(
richTextEditorState = composerState, richTextEditorState = composerState,
isFullScreen = isFullScreen, isFullScreen = isFullScreen,
@ -49,5 +51,6 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll, canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState, attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions, memberSuggestions = memberSuggestions,
currentUserId = UserId("@alice:localhost"),
eventSink = {}, eventSink = {},
) )

6
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.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents 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.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.Message 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.Suggestion
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@ -46,6 +47,7 @@ internal fun MessageComposerView(
enableVoiceMessages: Boolean, enableVoiceMessages: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val view = LocalView.current
fun sendMessage(message: Message) { fun sendMessage(message: Message) {
state.eventSink(MessageComposerEvents.SendMessage(message)) state.eventSink(MessageComposerEvents.SendMessage(message))
} }
@ -59,6 +61,7 @@ internal fun MessageComposerView(
} }
fun onDismissTextFormatting() { fun onDismissTextFormatting() {
view.clearFocus()
state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false)) state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
} }
@ -113,6 +116,7 @@ internal fun MessageComposerView(
onDeleteVoiceMessage = onDeleteVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage,
onSuggestionReceived = ::onSuggestionReceived, onSuggestionReceived = ::onSuggestionReceived,
onError = ::onError, onError = ::onError,
currentUserId = state.currentUserId,
) )
} }

31
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.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter 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.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.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher 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 // An empty suggestion returns the room and joined members that are not the current user
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
assertThat(awaitItem().memberSuggestions) 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 // A suggestion containing a part of "room" will also return the room mention
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo"))) 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 // A non-empty suggestion will return those joined members whose user id matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob"))) 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 // A non-empty suggestion will return those joined members whose display name matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave"))) 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 // If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
@ -776,7 +776,7 @@ class MessageComposerPresenterTest {
room.givenCanTriggerRoomNotification(Result.success(false)) room.givenCanTriggerRoomNotification(Result.success(false))
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
assertThat(awaitItem().memberSuggestions) 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 // If room is a DM, `RoomMemberSuggestion.Room` is not returned
room.givenCanTriggerRoomNotification(Result.success(true)) 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 // 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, ""))) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
assertThat(awaitItem().memberSuggestions) assertThat(awaitItem().memberSuggestions)
.containsExactly(RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david)) .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
}
}
@Test
fun `present - insertMention`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.richTextEditorState.setHtml("Hey @bo")
initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
assertThat(initialState.richTextEditorState.messageHtml)
.isEqualTo("Hey <a href='https://matrix.to/#/${A_USER_ID_2.value}'>${A_USER_ID_2.value}</a>")
} }
} }

39
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
}

30
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt

@ -108,6 +108,36 @@ val SemanticColors.pinDigitBg
Color(0xFF26282D) 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 @PreviewsDayNight
@Composable @Composable
internal fun ColorAliasesPreview() = ElementPreview { internal fun ColorAliasesPreview() = ElementPreview {

18
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> { private fun String.getViaParameters(): List<String> {
return UrlQuerySanitizer(this) return runCatching {
.parameterList UrlQuerySanitizer(this)
.filter { .parameterList
it.mParameter == "via" .filter {
} it.mParameter == "via"
.map { }
URLDecoder.decode(it.mValue, "UTF-8") .map {
} URLDecoder.decode(it.mValue, "UTF-8")
}
}.getOrDefault(emptyList())
} }
} }

3
libraries/textcomposer/impl/build.gradle.kts

@ -42,4 +42,7 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
} }

30
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.core.EventId 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.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.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail 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.VoiceMessagePreview
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape 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.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent 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.textcomposer.model.VoiceMessageState
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings 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.RichTextEditor
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import uniffi.wysiwyg_composer.MenuAction import uniffi.wysiwyg_composer.MenuAction
@ -95,6 +100,7 @@ fun TextComposer(
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
enableTextFormatting: Boolean, enableTextFormatting: Boolean,
enableVoiceMessages: Boolean, enableVoiceMessages: Boolean,
currentUserId: UserId,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTextFormatting: Boolean = false, showTextFormatting: Boolean = false,
subcomposing: Boolean = false, subcomposing: Boolean = false,
@ -143,6 +149,7 @@ fun TextComposer(
val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) { val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable { @Composable {
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId)
TextInput( TextInput(
state = state, state = state,
subcomposing = subcomposing, subcomposing = subcomposing,
@ -153,6 +160,8 @@ fun TextComposer(
}, },
composerMode = composerMode, composerMode = composerMode,
onResetComposerMode = onResetComposerMode, onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
onError = onError, onError = onError,
) )
} }
@ -385,6 +394,8 @@ private fun TextInput(
placeholder: String, placeholder: String,
composerMode: MessageComposerMode, composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit, onResetComposerMode: () -> Unit,
resolveRoomMentionDisplay: () -> TextDisplay,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onError: (Throwable) -> Unit = {}, onError: (Throwable) -> Unit = {},
) { ) {
@ -432,7 +443,11 @@ private fun TextInput(
.fillMaxWidth(), .fillMaxWidth(),
style = ElementRichTextEditorStyle.create( style = ElementRichTextEditorStyle.create(
hasFocus = state.hasFocus hasFocus = state.hasFocus
).copy(
pill = PillStyle(Color.Red)
), ),
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
onError = onError onError = onError
) )
} }
@ -584,6 +599,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost"),
) )
}, { }, {
TextComposer( TextComposer(
@ -594,6 +610,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -607,6 +624,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -617,6 +635,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}) })
) )
@ -633,6 +652,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -642,6 +662,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -651,6 +672,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
composerMode = MessageComposerMode.Normal, composerMode = MessageComposerMode.Normal,
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
})) }))
} }
@ -667,6 +689,7 @@ internal fun TextComposerEditPreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
})) }))
} }
@ -691,6 +714,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, },
{ {
@ -710,6 +734,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -731,6 +756,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -752,6 +778,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -773,6 +800,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}, { }, {
TextComposer( TextComposer(
@ -794,6 +822,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
}) })
) )
@ -813,6 +842,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
onResetComposerMode = {}, onResetComposerMode = {},
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = true, enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
) )
PreviewColumn(items = persistentListOf({ PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform())) VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform()))

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

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

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

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save