Plain text editor implementation based on markdown input (#2840)

* Add plain text editor based on markdown input
- Fix autofocus of message composer.
- Remove `Message` data class, fetch the details in `MessagesPresenter` instead.
- Remove `enable rich text` option from advanced settings, set it as a build configuration instead.
* Fix MentionSpanProvider
* Bump RTE library to released `v2.37.3`

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-05-21 13:58:53 +02:00 committed by GitHub
parent 3f2413bc95
commit 880ebb4de8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 1554 additions and 524 deletions

View file

@ -0,0 +1,184 @@
/*
* Copyright (c) 2024 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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun ComposerModeView(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
) {
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(onResetComposerMode = onResetComposerMode)
}
is MessageComposerMode.Reply -> {
ReplyToModeView(
modifier = Modifier.padding(8.dp),
senderName = composerMode.senderName,
text = composerMode.defaultContent,
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
onResetComposerMode = onResetComposerMode,
)
}
else -> Unit
}
}
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp)
) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = stringResource(CommonStrings.common_editing),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp),
)
Text(
stringResource(CommonStrings.common_editing),
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
)
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}
@Composable
private fun ReplyToModeView(
senderName: String,
text: String?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp)
) {
if (attachmentThumbnailInfo != null) {
AttachmentThumbnail(
info = attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(9.dp))
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
Text(
text = senderName,
modifier = Modifier
.fillMaxWidth()
.clipToBounds(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}

View file

@ -19,8 +19,6 @@ package io.element.android.libraries.textcomposer
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -34,30 +32,22 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.media.createFakeWaveform
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
@ -66,7 +56,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
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.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.testtags.TestTags
@ -79,11 +68,13 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteBu
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
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.Suggestion
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
@ -98,15 +89,14 @@ import kotlin.time.Duration.Companion.seconds
@Composable
fun TextComposer(
state: RichTextEditorState,
state: TextEditorState,
voiceMessageState: VoiceMessageState,
permalinkParser: PermalinkParser,
composerMode: MessageComposerMode,
enableTextFormatting: Boolean,
enableVoiceMessages: Boolean,
currentUserId: UserId,
onRequestFocus: () -> Unit,
onSendMessage: (Message) -> Unit,
onSendMessage: () -> Unit,
onResetComposerMode: () -> Unit,
onAddAttachment: () -> Unit,
onDismissTextFormatting: () -> Unit,
@ -122,9 +112,12 @@ fun TextComposer(
showTextFormatting: Boolean = false,
subcomposing: Boolean = false,
) {
val markdown = when (state) {
is TextEditorState.Markdown -> state.state.text.value()
is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown
}
val onSendClicked = {
val html = if (enableTextFormatting) state.messageHtml else null
onSendMessage(Message(html = html, markdown = state.messageMarkdown))
onSendMessage()
}
val onPlayVoiceMessageClicked = {
@ -153,32 +146,57 @@ fun TextComposer(
}
}
val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable {
val mentionSpanProvider = rememberMentionSpanProvider(
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
TextInput(
state = state,
subcomposing = subcomposing,
placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else {
stringResource(id = R.string.rich_text_editor_composer_placeholder)
},
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
onError = onError,
onTyping = onTyping,
onRichContentSelected = onRichContentSelected,
)
val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else {
stringResource(id = R.string.rich_text_editor_composer_placeholder)
}
val textInput: @Composable () -> Unit = when (state) {
is TextEditorState.Rich -> {
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable {
val mentionSpanProvider = rememberMentionSpanProvider(
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
TextInput(
state = state.richTextEditorState,
subcomposing = subcomposing,
placeholder = placeholder,
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
onError = onError,
onTyping = onTyping,
onRichContentSelected = onRichContentSelected,
)
}
}
}
is TextEditorState.Markdown -> {
@Composable {
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus())
TextInputBox(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
placeholder = placeholder,
showPlaceholder = { state.state.text.value().isEmpty() },
subcomposing = subcomposing,
) {
MarkdownTextInput(
state = state.state,
subcomposing = subcomposing,
onTyping = onTyping,
onSuggestionReceived = onSuggestionReceived,
richTextEditorStyle = style,
)
}
}
}
}
val canSendMessage by remember { derivedStateOf { state.messageMarkdown.isNotBlank() } }
val canSendMessage = markdown.isNotBlank()
val sendButton = @Composable {
SendButton(
canSendMessage = canSendMessage,
@ -205,7 +223,9 @@ fun TextComposer(
)
}
val textFormattingOptions = @Composable { TextFormatting(state = state) }
val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let {
@Composable { TextFormatting(state = it.richTextEditorState) }
}
val sendOrRecordButton = when {
enableVoiceMessages && !canSendMessage ->
@ -217,8 +237,7 @@ fun TextComposer(
false -> sendVoiceButton
}
}
else ->
sendButton
else -> sendButton
}
val voiceRecording = @Composable {
@ -251,7 +270,7 @@ fun TextComposer(
}
}
if (showTextFormatting) {
if (showTextFormatting && textFormattingOptions != null) {
TextFormattingLayout(
modifier = layoutModifier,
textInput = textInput,
@ -282,14 +301,16 @@ fun TextComposer(
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
}
val menuAction = state.menuAction
val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived)
LaunchedEffect(menuAction) {
if (menuAction is MenuAction.Suggestion) {
val suggestion = Suggestion(menuAction.suggestionPattern)
latestOnSuggestionReceived(suggestion)
} else {
latestOnSuggestionReceived(null)
if (state is TextEditorState.Rich) {
val menuAction = state.richTextEditorState.menuAction
LaunchedEffect(menuAction) {
if (menuAction is MenuAction.Suggestion) {
val suggestion = Suggestion(menuAction.suggestionPattern)
latestOnSuggestionReceived(suggestion)
} else {
latestOnSuggestionReceived(null)
}
}
}
}
@ -400,17 +421,13 @@ private fun TextFormattingLayout(
}
@Composable
private fun TextInput(
state: RichTextEditorState,
subcomposing: Boolean,
placeholder: String,
private fun TextInputBox(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
resolveRoomMentionDisplay: () -> TextDisplay,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
onError: (Throwable) -> Unit,
onTyping: (Boolean) -> Unit,
onRichContentSelected: ((Uri) -> Unit)?,
placeholder: String,
showPlaceholder: () -> Boolean,
subcomposing: Boolean,
textInput: @Composable () -> Unit,
) {
val bgColor = ElementTheme.colors.bgSubtleSecondary
val borderColor = ElementTheme.colors.borderDisabled
@ -431,11 +448,12 @@ private fun TextInput(
Box(
modifier = Modifier
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp)
.testTag(TestTags.richTextEditor),
// Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail
.then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier),
contentAlignment = Alignment.CenterStart,
) {
// Placeholder
if (state.messageHtml.isEmpty()) {
if (showPlaceholder()) {
Text(
placeholder,
style = defaultTypography.copy(
@ -446,155 +464,45 @@ private fun TextInput(
)
}
RichTextEditor(
state = state,
// Disable most of the editor functionality if it's just being measured for a subcomposition.
// This prevents it gaining focus and mutating the state.
registerStateUpdates = !subcomposing,
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
onError = onError,
onRichContentSelected = onRichContentSelected,
onTyping = onTyping,
)
textInput()
}
}
}
@Composable
private fun ComposerModeView(
private fun TextInput(
state: RichTextEditorState,
subcomposing: Boolean,
placeholder: String,
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
resolveRoomMentionDisplay: () -> TextDisplay,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
onError: (Throwable) -> Unit,
onTyping: (Boolean) -> Unit,
onRichContentSelected: ((Uri) -> Unit)?,
) {
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(onResetComposerMode = onResetComposerMode)
}
is MessageComposerMode.Reply -> {
ReplyToModeView(
modifier = Modifier.padding(8.dp),
senderName = composerMode.senderName,
text = composerMode.defaultContent,
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
onResetComposerMode = onResetComposerMode,
)
}
else -> Unit
}
}
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp)
TextInputBox(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
placeholder = placeholder,
showPlaceholder = { state.messageHtml.isEmpty() },
subcomposing = subcomposing,
) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = stringResource(CommonStrings.common_editing),
tint = ElementTheme.materialColors.secondary,
RichTextEditor(
state = state,
// Disable most of the editor functionality if it's just being measured for a subcomposition.
// This prevents it gaining focus and mutating the state.
registerStateUpdates = !subcomposing,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp),
)
Text(
stringResource(CommonStrings.common_editing),
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
)
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}
@Composable
private fun ReplyToModeView(
senderName: String,
text: String?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp)
) {
if (attachmentThumbnailInfo != null) {
AttachmentThumbnail(
info = attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(9.dp))
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
Text(
text = senderName,
modifier = Modifier
.fillMaxWidth()
.clipToBounds(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
onError = onError,
onRichContentSelected = onRichContentSelected,
onTyping = onTyping,
)
}
}
@ -606,43 +514,41 @@ internal fun TextComposerSimplePreview() = ElementPreview {
items = persistentListOf(
{
ATextComposer(
aRichTextEditorState(initialText = "", initialFocus = true),
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "", initialFocus = true)),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost"),
)
},
{
ATextComposer(
aRichTextEditorState(initialText = "A message", initialFocus = true),
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
ATextComposer(
aRichTextEditorState(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
initialFocus = true
TextEditorState.Markdown(
aMarkdownTextEditorState(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
initialFocus = true
)
),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
ATextComposer(
aRichTextEditorState(initialText = "A message without focus"),
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus", initialFocus = false)),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
@ -656,33 +562,32 @@ internal fun TextComposerSimplePreview() = ElementPreview {
internal fun TextComposerFormattingPreview() = ElementPreview {
PreviewColumn(items = persistentListOf({
ATextComposer(
aRichTextEditorState(),
TextEditorState.Rich(aRichTextEditorState()),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}, {
ATextComposer(
aRichTextEditorState(initialText = "A message"),
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}, {
ATextComposer(
aRichTextEditorState(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
TextEditorState.Rich(
aRichTextEditorState(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
)
),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
@ -694,10 +599,23 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
internal fun TextComposerEditPreview() = ElementPreview {
PreviewColumn(items = persistentListOf({
ATextComposer(
aRichTextEditorState(initialText = "A message", initialFocus = true),
TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}))
}
@PreviewsDayNight
@Composable
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
PreviewColumn(items = persistentListOf({
ATextComposer(
TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
@ -711,7 +629,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
items = persistentListOf(
{
ATextComposer(
aRichTextEditorState(),
TextEditorState.Rich(aRichTextEditorState()),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -722,14 +640,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
"With several lines\n" +
"To preview larger textfields and long lines with overflow"
),
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
ATextComposer(
aRichTextEditorState(),
TextEditorState.Rich(aRichTextEditorState()),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = true,
@ -740,14 +657,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
"With several lines\n" +
"To preview larger textfields and long lines with overflow"
),
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
ATextComposer(
aRichTextEditorState(initialText = "A message"),
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = true,
@ -761,14 +677,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
),
defaultContent = "image.jpg"
),
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
ATextComposer(
aRichTextEditorState(initialText = "A message"),
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -782,14 +697,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
),
defaultContent = "video.mp4"
),
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
ATextComposer(
aRichTextEditorState(initialText = "A message"),
TextEditorState.Rich(aRichTextEditorState(initialText = "A message")),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -803,14 +717,13 @@ internal fun TextComposerReplyPreview() = ElementPreview {
),
defaultContent = "logs.txt"
),
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
ATextComposer(
aRichTextEditorState(initialText = "A message", initialFocus = true),
TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -824,7 +737,6 @@ internal fun TextComposerReplyPreview() = ElementPreview {
),
defaultContent = "Shared location"
),
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
@ -840,10 +752,9 @@ internal fun TextComposerVoicePreview() = ElementPreview {
fun VoicePreview(
voiceMessageState: VoiceMessageState
) = ATextComposer(
aRichTextEditorState(initialFocus = true),
TextEditorState.Rich(aRichTextEditorState(initialFocus = true)),
voiceMessageState = voiceMessageState,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
@ -902,23 +813,21 @@ private fun PreviewColumn(
@Composable
private fun ATextComposer(
richTextEditorState: RichTextEditorState,
state: TextEditorState,
voiceMessageState: VoiceMessageState,
composerMode: MessageComposerMode,
enableTextFormatting: Boolean,
enableVoiceMessages: Boolean,
currentUserId: UserId,
showTextFormatting: Boolean = false,
) {
TextComposer(
state = richTextEditorState,
state = state,
showTextFormatting = showTextFormatting,
voiceMessageState = voiceMessageState,
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented")
},
composerMode = composerMode,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
currentUserId = currentUserId,
onRequestFocus = {},

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2024 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.components.markdown
import android.content.Context
import androidx.appcompat.widget.AppCompatEditText
internal class MarkdownEditText(
context: Context,
) : AppCompatEditText(context) {
var onSelectionChangeListener: ((Int, Int) -> Unit)? = null
private var isModifyingText = false
fun updateEditableText(charSequence: CharSequence) {
isModifyingText = true
editableText.clear()
editableText.append(charSequence)
isModifyingText = false
}
override fun setText(text: CharSequence?, type: BufferType?) {
isModifyingText = true
super.setText(text, type)
isModifyingText = false
}
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
if (!isModifyingText) {
onSelectionChangeListener?.invoke(selStart, selEnd)
}
}
}

View file

@ -0,0 +1,160 @@
/*
* Copyright (c) 2024 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.components.markdown
import android.graphics.Color
import android.text.Editable
import android.text.Selection
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.getSpans
import androidx.core.view.setPadding
import androidx.core.widget.addTextChangedListener
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.wysiwyg.compose.RichTextEditorStyle
import io.element.android.wysiwyg.compose.internal.applyStyleInCompose
@Suppress("ModifierMissing")
@Composable
fun MarkdownTextInput(
state: MarkdownTextEditorState,
subcomposing: Boolean,
onTyping: (Boolean) -> Unit,
onSuggestionReceived: (Suggestion?) -> Unit,
richTextEditorStyle: RichTextEditorStyle,
) {
val canUpdateState = !subcomposing
AndroidView(
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
factory = { context ->
MarkdownEditText(context).apply {
tag = TestTags.plainTextEditor.value // Needed for UI tests
setPadding(0)
setBackgroundColor(Color.TRANSPARENT)
setText(state.text.value())
if (canUpdateState) {
setSelection(state.selection.first, state.selection.last)
setOnFocusChangeListener { _, hasFocus ->
state.hasFocus = hasFocus
}
addTextChangedListener { editable ->
onTyping(!editable.isNullOrEmpty())
state.text.update(editable, false)
state.lineCount = lineCount
state.currentMentionSuggestion = editable?.checkSuggestionNeeded()
onSuggestionReceived(state.currentMentionSuggestion)
}
onSelectionChangeListener = { selStart, selEnd ->
state.selection = selStart..selEnd
state.currentMentionSuggestion = editableText.checkSuggestionNeeded()
onSuggestionReceived(state.currentMentionSuggestion)
}
state.requestFocusAction = { this.requestFocus() }
}
}
},
update = { editText ->
editText.applyStyleInCompose(richTextEditorStyle)
if (state.text.needsDisplaying()) {
editText.updateEditableText(state.text.value())
if (canUpdateState) {
state.text.update(editText.editableText, false)
}
}
if (canUpdateState) {
val newSelectionStart = state.selection.first
val newSelectionEnd = state.selection.last
val currentTextRange = 0..editText.editableText.length
val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd }
val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange }
if (didSelectionChange() && isNewSelectionValid()) {
editText.setSelection(state.selection.first, state.selection.last)
}
}
}
)
}
private fun Editable.checkSuggestionNeeded(): Suggestion? {
if (this.isEmpty()) return null
val start = Selection.getSelectionStart(this)
val end = Selection.getSelectionEnd(this)
var startOfWord = start
while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) {
startOfWord--
}
if (startOfWord !in indices) return null
val firstChar = this[startOfWord]
// If a mention span already exists we don't need suggestions
if (getSpans<MentionSpan>(startOfWord, startOfWord + 1).isNotEmpty()) return null
return if (firstChar in listOf('@', '#', '/')) {
var endOfWord = end
while (endOfWord < this.length && !this[endOfWord].isWhitespace()) {
endOfWord++
}
val text = this.subSequence(startOfWord + 1, endOfWord).toString()
val suggestionType = when (firstChar) {
'@' -> SuggestionType.Mention
'#' -> SuggestionType.Room
'/' -> SuggestionType.Command
else -> error("Unknown suggestion type. This should never happen.")
}
Suggestion(startOfWord, endOfWord, suggestionType, text)
} else {
null
}
}
@PreviewsDayNight
@Composable
internal fun MarkdownTextInputPreview() {
ElementPreview {
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = true)
MarkdownTextInput(
state = aMarkdownTextEditorState(),
subcomposing = false,
onTyping = {},
onSuggestionReceived = {},
richTextEditorStyle = style,
)
}
}
internal fun aMarkdownTextEditorState(
initialText: String = "Hello, World!",
initialFocus: Boolean = true,
) = MarkdownTextEditorState(
initialText = initialText,
initialFocus = initialFocus,
)

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 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.components.markdown
import android.text.SpannableString
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.element.android.libraries.core.extensions.orEmpty
@Stable
class StableCharSequence(initialText: CharSequence = "") {
private var value by mutableStateOf<SpannableString>(SpannableString(initialText))
private var needsDisplaying by mutableStateOf(false)
fun update(newText: CharSequence?, needsDisplaying: Boolean) {
value = SpannableString(newText.orEmpty())
this.needsDisplaying = needsDisplaying
}
fun value(): CharSequence = value
fun needsDisplaying(): Boolean = needsDisplaying
override fun toString(): String {
return "ImmutableCharSequence(value='$value', needsDisplaying=$needsDisplaying)"
}
}

View file

@ -26,6 +26,8 @@ import kotlin.math.min
import kotlin.math.roundToInt
class MentionSpan(
val text: String,
val rawValue: String,
val type: Type,
val backgroundColor: Int,
val textColor: Int,
@ -39,29 +41,25 @@ class MentionSpan(
private var actualText: CharSequence? = null
private var textWidth = 0
private var cachedRect: RectF = RectF()
private val backgroundPaint = Paint().apply {
isAntiAlias = true
color = backgroundColor
}
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
val mentionText = getActualText(text, start, end)
val mentionText = getActualText(this.text)
paint.typeface = typeface
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
return textWidth + startPadding + endPadding
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
val mentionText = getActualText(text, start, end)
val mentionText = getActualText(this.text)
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
if (cachedRect.isEmpty) {
cachedRect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
}
val rect = cachedRect
val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace)
val radius = rect.height() / 2
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
paint.color = textColor
@ -69,24 +67,24 @@ class MentionSpan(
canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint)
}
private fun getActualText(text: CharSequence?, start: Int, end: Int): CharSequence {
private fun getActualText(text: String): CharSequence {
if (actualText != null) return actualText!!
return buildString {
val mentionText = text.orEmpty()
when (type) {
Type.USER -> {
if (start in mentionText.indices && mentionText[start] != '@') {
if (text.firstOrNull() != '@') {
append("@")
}
}
Type.ROOM -> {
if (start in mentionText.indices && mentionText[start] != '#') {
if (text.firstOrNull() != '#') {
append("#")
}
}
}
append(mentionText.substring(start, min(end, start + MAX_LENGTH)))
if (end - start > MAX_LENGTH) {
append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH)))
if (mentionText.length > MAX_LENGTH) {
append("")
}
actualText = this

View file

@ -84,6 +84,8 @@ class MentionSpanProvider(
permalinkData is PermalinkData.UserLink -> {
val isCurrentUser = permalinkData.userId == currentSessionId
MentionSpan(
text = text,
rawValue = permalinkData.userId.toString(),
type = MentionSpan.Type.USER,
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
@ -94,6 +96,8 @@ class MentionSpanProvider(
}
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
MentionSpan(
text = text,
rawValue = "@room",
type = MentionSpan.Type.USER,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
@ -102,8 +106,22 @@ class MentionSpanProvider(
typeface = typeface.value,
)
}
permalinkData is PermalinkData.RoomLink -> {
MentionSpan(
text = text,
rawValue = permalinkData.roomIdOrAlias.toString(),
type = MentionSpan.Type.ROOM,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
startPadding = startPaddingPx,
endPadding = endPaddingPx,
typeface = typeface.value,
)
}
else -> {
MentionSpan(
text = text,
rawValue = text,
type = MentionSpan.Type.ROOM,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
@ -155,8 +173,8 @@ internal fun MentionSpanPreview() {
provider.setup()
val textColor = ElementTheme.colors.textPrimary.toArgb()
fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
AndroidView(factory = { context ->
TextView(context).apply {

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 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 androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.room.RoomMember
@Immutable
sealed interface ResolvedMentionSuggestion {
data object AtRoom : ResolvedMentionSuggestion
data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion
}

View file

@ -0,0 +1,122 @@
/*
* Copyright (c) 2024 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.model
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.text.getSpans
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
@Stable
class MarkdownTextEditorState(
initialText: String?,
initialFocus: Boolean,
) {
var text by mutableStateOf(StableCharSequence(initialText ?: ""))
var selection by mutableStateOf(0..0)
var hasFocus by mutableStateOf(initialFocus)
var requestFocusAction by mutableStateOf({})
var lineCount by mutableIntStateOf(1)
var currentMentionSuggestion by mutableStateOf<Suggestion?>(null)
fun insertMention(
mention: ResolvedMentionSuggestion,
mentionSpanProvider: MentionSpanProvider,
permalinkBuilder: PermalinkBuilder,
) {
val suggestion = currentMentionSuggestion ?: return
when (mention) {
is ResolvedMentionSuggestion.AtRoom -> {
val currentText = SpannableStringBuilder(text.value())
val replaceText = "@room"
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
currentText.replace(suggestion.start, suggestion.end, ". ")
val end = suggestion.start + 1
currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedMentionSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value())
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
currentText.replace(suggestion.start, suggestion.end, ". ")
val end = suggestion.start + 1
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
}
}
}
fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String {
val charSequence = text.value()
return if (charSequence is Spanned) {
val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java)
buildString {
append(charSequence.toString())
if (mentions != null && mentions.isNotEmpty()) {
for (mention in mentions.reversed()) {
val start = charSequence.getSpanStart(mention)
val end = charSequence.getSpanEnd(mention)
if (mention.type == MentionSpan.Type.USER) {
if (mention.rawValue == "@room") {
replace(start, end, "@room")
} else {
val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue
replace(start, end, "[${mention.text}]($link)")
}
}
}
}
}
} else {
charSequence.toString()
}
}
fun getMentions(): List<Mention> {
val text = SpannableString(text.value())
val mentionSpans = text.getSpans<MentionSpan>(0, text.length)
return mentionSpans.mapNotNull { mentionSpan ->
when (mentionSpan.type) {
MentionSpan.Type.USER -> {
if (mentionSpan.rawValue == "@room") {
Mention.AtRoom
} else {
Mention.User(UserId(mentionSpan.rawValue))
}
}
else -> null
}
}
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2024 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.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.wysiwyg.compose.RichTextEditorState
@Immutable
sealed interface TextEditorState {
data class Markdown(
val state: MarkdownTextEditorState,
) : TextEditorState
data class Rich(
val richTextEditorState: RichTextEditorState
) : TextEditorState
fun messageHtml(): String? = when (this) {
is Markdown -> null
is Rich -> richTextEditorState.messageHtml
}
fun messageMarkdown(permalinkBuilder: PermalinkBuilder): String = when (this) {
is Markdown -> state.getMessageMarkdown(permalinkBuilder)
is Rich -> richTextEditorState.messageMarkdown
}
fun hasFocus(): Boolean = when (this) {
is Markdown -> state.hasFocus
is Rich -> richTextEditorState.hasFocus
}
suspend fun reset() {
when (this) {
is Markdown -> {
state.selection = IntRange.EMPTY
state.text.update("", true)
}
is Rich -> richTextEditorState.setHtml("")
}
}
suspend fun requestFocus() {
when (this) {
is Markdown -> state.requestFocusAction()
is Rich -> richTextEditorState.requestFocus()
}
}
val lineCount: Int get() = when (this) {
is Markdown -> state.lineCount
is Rich -> richTextEditorState.lineCount
}
}

View file

@ -0,0 +1,201 @@
/*
* Copyright (c) 2024 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.components.markdown
import android.widget.EditText
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.core.text.getSpans
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EventsRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MarkdownTextInputTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `when user types onTyping is triggered with value 'true'`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onTyping = EnsureCalledOnceWithParam(expectedParam = true, result = Unit)
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
rule.activityRule.scenario.onActivity {
it.findEditor().setText("Test")
}
rule.awaitIdle()
onTyping.assertSuccess()
}
@Test
fun `when user removes text onTyping is triggered with value 'false'`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onTyping = EventsRecorder<Boolean>()
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
rule.activityRule.scenario.onActivity {
val editText = it.findEditor()
editText.setText("Test")
editText.setText("")
editText.setText(null)
}
rule.awaitIdle()
onTyping.assertList(listOf(true, false, false))
}
@Test
fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onSuggestionReceived = EventsRecorder<Suggestion?>()
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
rule.activityRule.scenario.onActivity {
it.findEditor().setText("Test")
}
rule.awaitIdle()
onSuggestionReceived.assertSingle(null)
}
@Test
fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onSuggestionReceived = EventsRecorder<Suggestion?>()
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
rule.activityRule.scenario.onActivity {
it.findEditor().setText("@")
it.findEditor().setText("#")
it.findEditor().setText("/")
}
rule.awaitIdle()
onSuggestionReceived.assertList(
listOf(
// User mention suggestion
Suggestion(0, 1, SuggestionType.Mention, ""),
// Room suggestion
Suggestion(0, 1, SuggestionType.Room, ""),
// Slash command suggestion
Suggestion(0, 1, SuggestionType.Command, ""),
)
)
}
@Test
fun `when the selection changes in the UI the state is updated`() = runTest {
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
rule.setMarkdownTextInput(state = state)
rule.activityRule.scenario.onActivity {
val editor = it.findEditor()
editor.setSelection(2)
}
rule.awaitIdle()
// Selection is updated
assertThat(state.selection).isEqualTo(2..2)
}
@Test
fun `when the selection state changes in the view is updated`() = runTest {
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
rule.setMarkdownTextInput(state = state)
var editor: EditText? = null
rule.activityRule.scenario.onActivity {
editor = it.findEditor()
state.selection = 2..2
}
rule.awaitIdle()
// Selection state is updated
assertThat(editor?.selectionStart).isEqualTo(2)
assertThat(editor?.selectionEnd).isEqualTo(2)
}
@Test
fun `when the view focus changes the state is updated`() = runTest {
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false)
rule.setMarkdownTextInput(state = state)
rule.activityRule.scenario.onActivity {
val editor = it.findEditor()
editor.requestFocus()
}
// Focus state is updated
assertThat(state.hasFocus).isTrue()
}
@Test
fun `inserting a mention replaces the existing text with a span`() = runTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$A_SESSION_ID") })
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
rule.setMarkdownTextInput(state = state)
var editor: EditText? = null
rule.activityRule.scenario.onActivity {
editor = it.findEditor()
state.insertMention(
ResolvedMentionSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser),
permalinkBuilder,
)
}
rule.awaitIdle()
// Text is replaced with a placeholder
assertThat(editor?.editableText.toString()).isEqualTo(". ")
// The placeholder contains a MentionSpan
val mentionSpans = editor?.editableText?.getSpans<MentionSpan>(0, 2).orEmpty()
assertThat(mentionSpans).isNotEmpty()
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMarkdownTextInput(
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
subcomposing: Boolean = false,
onTyping: (Boolean) -> Unit = {},
onSuggestionReceived: (Suggestion?) -> Unit = {},
) {
rule.setContent {
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus)
MarkdownTextInput(
state = state,
subcomposing = subcomposing,
onTyping = onTyping,
onSuggestionReceived = onSuggestionReceived,
richTextEditorStyle = style,
)
}
}
private fun ComponentActivity.findEditor(): EditText {
return window.decorView.findViewWithTag(TestTags.plainTextEditor.value)
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.textcomposer.impl.mentions
import android.graphics.Color
import android.net.Uri
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
@ -66,6 +67,14 @@ class MentionSpanProviderTest {
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
}
@Test
fun `getting mention span for everyone in the room`() {
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
}
@Test
fun `getting mention span for a room should return a MentionSpan with normal colors`() {
permalinkParser.givenResult(

View file

@ -0,0 +1,171 @@
/*
* Copyright (c) 2024 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.model
import android.net.Uri
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
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.room.Mention
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MarkdownTextEditorStateTest {
@Test
fun `insertMention - with no currentMentionSuggestion does nothing`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider()
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `insertMention - with member but failed PermalinkBuilder result`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(result = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isEmpty()
}
@Test
fun `insertMention - with member`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/${member.userId}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId)
}
@Test
fun `insertMention - with @room`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val mention = ResolvedMentionSuggestion.AtRoom
val permalinkBuilder = FakePermalinkBuilder()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java)
}
@Test
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
val text = "No mentions here"
val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
val markdown = state.getMessageMarkdown(FakePermalinkBuilder())
assertThat(markdown).isEqualTo(text)
}
@Test
fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() {
val text = "No mentions here"
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$it") })
val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo(
"Hello [@Alice](https://matrix.to/#/@alice:matrix.org) and everyone in @room"
)
}
@Test
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java)
}
private fun aMentionSpanProvider(
currentSessionId: SessionId = A_SESSION_ID,
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
): MentionSpanProvider {
return MentionSpanProvider(currentSessionId, permalinkParser)
}
private fun aMarkdownTextWithMentions(): CharSequence {
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER, 0, 0, 0, 0)
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.USER, 0, 0, 0, 0)
return buildSpannedString {
append("Hello ")
inSpans(userMentionSpan) {
append("@")
}
append(" and everyone in ")
inSpans(atRoomMentionSpan) {
append("@")
}
}
}
}