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:
parent
3f2413bc95
commit
880ebb4de8
94 changed files with 1554 additions and 524 deletions
|
|
@ -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)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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("@")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue