First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -0,0 +1,49 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.textcomposer"
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
releaseApi(libs.matrix.richtexteditor)
releaseApi(libs.matrix.richtexteditor.compose)
if (file("${rootDir.path}/libraries/textcomposer/lib/library-compose.aar").exists()) {
println("\nNote: Using local binaries of the Rich Text Editor.\n")
debugApi(projects.libraries.textcomposer.lib)
} else {
debugApi(libs.matrix.richtexteditor)
debugApi(libs.matrix.richtexteditor.compose)
}
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CaptionWarningBottomSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BigIcon(
style = BigIcon.Style.AlertSolid,
)
Text(
text = stringResource(R.string.screen_media_upload_preview_caption_warning),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
OutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
onClick = onDismiss,
text = stringResource(CommonStrings.action_ok),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun CaptionWarningBottomSheetPreview() = ElementPreview {
CaptionWarningBottomSheet(
onDismiss = {},
)
}
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun ComposerModeView(
composerMode: MessageComposerMode.Special,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(
text = stringResource(CommonStrings.common_editing),
modifier = modifier,
onResetComposerMode = onResetComposerMode,
)
}
is MessageComposerMode.EditCaption -> {
EditingModeView(
text = stringResource(
if (composerMode.content.isEmpty()) CommonStrings.common_adding_caption else CommonStrings.common_editing_caption
),
modifier = modifier,
onResetComposerMode = onResetComposerMode,
)
}
is MessageComposerMode.Reply -> {
ReplyToModeView(
modifier = modifier.padding(8.dp),
replyToDetails = composerMode.replyToDetails,
hideImage = composerMode.hideImage,
onResetComposerMode = onResetComposerMode,
)
}
}
}
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
text: String,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(start = 12.dp)
) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp),
)
Text(
text = text,
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.colors.textSecondary,
modifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
)
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.colors.iconSecondary,
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 = ripple(bounded = false)
),
)
}
}
@Composable
private fun ReplyToModeView(
replyToDetails: InReplyToDetails,
hideImage: Boolean,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp)
) {
InReplyToView(
inReplyTo = replyToDetails,
hideImage = hideImage,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier
.padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false)
),
)
}
}
@PreviewsDayNight
@Composable
internal fun ComposerModeViewPreview(
@PreviewParameter(MessageComposerModeSpecialProvider::class) mode: MessageComposerMode.Special
) = ElementPreview {
ComposerModeView(
composerMode = mode,
onResetComposerMode = {},
modifier = Modifier.background(ElementTheme.colors.bgSubtleSecondary)
)
}
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
import io.element.android.wysiwyg.compose.RichTextEditorStyle
object ElementRichTextEditorStyle {
@Composable
fun composerStyle(
hasFocus: Boolean,
): RichTextEditorStyle {
val baseStyle = common()
return baseStyle.copy(
text = baseStyle.text.copy(
color = if (hasFocus) {
ElementTheme.colors.textPrimary
} else {
ElementTheme.colors.textSecondary
},
placeholderColor = ElementTheme.colors.textSecondary,
lineHeight = TextUnit.Unspecified,
includeFontPadding = true,
)
)
}
@Composable
fun textStyle(): RichTextEditorStyle {
return common()
}
@Composable
private fun common(): RichTextEditorStyle {
val colors = ElementTheme.colors
val codeCornerRadius = 4.dp
val codeBorderWidth = 1.dp
return RichTextEditorDefaults.style(
text = RichTextEditorDefaults.textStyle(
color = LocalTextStyle.current.color.takeIf { it.isSpecified } ?: LocalContentColor.current,
fontStyle = LocalTextStyle.current.fontStyle,
lineHeight = LocalTextStyle.current.lineHeight,
includeFontPadding = false,
),
cursor = RichTextEditorDefaults.cursorStyle(
color = colors.iconAccentTertiary,
),
link = RichTextEditorDefaults.linkStyle(
color = colors.textLinkExternal,
),
codeBlock = RichTextEditorDefaults.codeBlockStyle(
leadingMargin = 8.dp,
background = RichTextEditorDefaults.codeBlockBackgroundStyle(
color = colors.bgSubtleTertiary,
borderColor = colors.borderInteractiveSecondary,
cornerRadius = codeCornerRadius,
borderWidth = codeBorderWidth,
)
),
inlineCode = RichTextEditorDefaults.inlineCodeStyle(
verticalPadding = 0.dp,
background = RichTextEditorDefaults.inlineCodeBackgroundStyle(
color = colors.bgSubtleTertiary,
borderColor = colors.borderInteractiveSecondary,
cornerRadius = codeCornerRadius,
borderWidth = codeBorderWidth,
)
),
)
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.textcomposer.model.MessageComposerMode
class MessageComposerModeSpecialProvider : PreviewParameterProvider<MessageComposerMode.Special> {
override val values: Sequence<MessageComposerMode.Special> = sequenceOf(
aMessageComposerModeEdit()
) +
// Keep only 3 values from InReplyToDetailsProvider
InReplyToDetailsProvider().values.take(3).map {
aMessageComposerModeReply(
replyToDetails = it
)
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.libraries.androidutils.ui.awaitWindowFocus
import io.element.android.libraries.androidutils.ui.isKeyboardVisible
import io.element.android.libraries.androidutils.ui.showKeyboard
/**
* Shows the soft keyboard when a given key changes to meet the required condition.
*
* Uses [showKeyboard] to show the keyboard for compatibility with [AndroidView].
*
* @param T
* @param key The key to watch for changes.
* @param onRequestFocus A callback to request focus to the view that will receive the keyboard input.
* @param predicate The predicate that [key] must meet before showing the keyboard.
*/
@Composable
internal fun <T> SoftKeyboardEffect(
key: T,
onRequestFocus: () -> Unit,
predicate: (T) -> Boolean,
) {
val view = LocalView.current
val latestOnRequestFocus by rememberUpdatedState(onRequestFocus)
val latestPredicate by rememberUpdatedState(predicate)
LaunchedEffect(key) {
if (latestPredicate(key)) {
// Await window focus in case returning from a dialog
view.awaitWindowFocus()
if (!view.isKeyboardVisible()) {
// Show the keyboard, temporarily using the root view for focus
view.showKeyboard(andRequestFocus = true)
// Refocus to the correct view
latestOnRequestFocus()
}
}
}
}
@@ -0,0 +1,965 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer
import android.content.res.Configuration
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
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
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.androidutils.ui.showKeyboard
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.designsystem.preview.DAY_MODE_NAME
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.NIGHT_MODE_NAME
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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconColorButton
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.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.textcomposer.components.SendButton
import io.element.android.libraries.textcomposer.components.TextFormatting
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton
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.textInputRoundedCornerShape
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
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import io.element.android.libraries.textcomposer.model.showCaptionCompatibilityWarning
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.RichTextEditor
import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import uniffi.wysiwyg_composer.MenuAction
import kotlin.time.Duration.Companion.seconds
@Composable
fun TextComposer(
state: TextEditorState,
voiceMessageState: VoiceMessageState,
composerMode: MessageComposerMode,
onRequestFocus: () -> Unit,
onSendMessage: () -> Unit,
onResetComposerMode: () -> Unit,
onAddAttachment: () -> Unit,
onDismissTextFormatting: () -> Unit,
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit,
onVoicePlayerEvent: (VoiceMessagePlayerEvent) -> Unit,
onSendVoiceMessage: () -> Unit,
onDeleteVoiceMessage: () -> Unit,
onError: (Throwable) -> Unit,
onTyping: (Boolean) -> Unit,
onReceiveSuggestion: (Suggestion?) -> Unit,
onSelectRichContent: ((Uri) -> Unit)?,
resolveMentionDisplay: (text: String, url: String) -> TextDisplay,
resolveAtRoomMentionDisplay: () -> TextDisplay,
modifier: Modifier = Modifier,
showTextFormatting: Boolean = false,
) {
val markdown = when (state) {
is TextEditorState.Markdown -> state.state.text.value()
is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown
}
val onSendClick = {
onSendMessage()
}
val onPlayVoiceMessageClick = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Play)
}
val onPauseVoiceMessageClick = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Pause)
}
val onSeekVoiceMessage = { position: Float ->
onVoicePlayerEvent(VoiceMessagePlayerEvent.Seek(position))
}
val layoutModifier = modifier
.fillMaxSize()
.height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable {
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(9.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(16.dp))
}
else -> {
IconColorButton(
onClick = onAddAttachment,
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
)
}
}
}
}
val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) {
stringResource(id = R.string.rich_text_editor_composer_caption_placeholder)
} else {
stringResource(id = R.string.rich_text_editor_composer_placeholder)
}
val textInput: @Composable () -> Unit = when (state) {
is TextEditorState.Rich -> {
val coroutineScope = rememberCoroutineScope()
val view = LocalView.current
remember(state.richTextEditorState, composerMode, onResetComposerMode, onError) {
@Composable {
TextInputBox(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
coroutineScope.launch {
state.requestFocus()
view.showKeyboard()
}
}
.semantics {
hideFromAccessibility()
},
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
isTextEmpty = state.richTextEditorState.messageHtml.isEmpty(),
) {
RichTextEditor(
state = state.richTextEditorState,
placeholder = placeholder,
registerStateUpdates = true,
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.richTextEditorState.hasFocus),
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveAtRoomMentionDisplay,
onError = onError,
onRichContentSelected = onSelectRichContent,
onTyping = onTyping,
)
}
}
}
}
is TextEditorState.Markdown -> {
@Composable {
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus())
TextInputBox(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
isTextEmpty = state.state.text.value().isEmpty(),
) {
MarkdownTextInput(
state = state.state,
placeholder = placeholder,
placeholderColor = ElementTheme.colors.textSecondary,
onTyping = onTyping,
onReceiveSuggestion = onReceiveSuggestion,
richTextEditorStyle = style,
onSelectRichContent = onSelectRichContent,
)
}
}
}
}
val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment
val sendButton = @Composable {
SendButton(
canSendMessage = canSendMessage,
onClick = onSendClick,
composerMode = composerMode,
)
}
val recordVoiceButton = @Composable {
VoiceMessageRecorderButton(
isRecording = voiceMessageState is VoiceMessageState.Recording,
onEvent = onVoiceRecorderEvent,
)
}
val sendVoiceButton = @Composable {
SendButton(
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
onClick = onSendVoiceMessage,
composerMode = composerMode,
)
}
val uploadVoiceProgress = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let {
@Composable { TextFormatting(state = it.richTextEditorState) }
}
val sendOrRecordButton = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> uploadVoiceProgress
false -> sendVoiceButton
}
}
else -> sendButton
}
val endButtonA11y = endButtonA11y(
composerMode = composerMode,
voiceMessageState = voiceMessageState,
canSendMessage = canSendMessage,
)
val voiceRecording = @Composable {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessagePreview(
isInteractive = !voiceMessageState.isSending,
isPlaying = voiceMessageState.isPlaying,
showCursor = voiceMessageState.showCursor,
waveform = voiceMessageState.waveform,
playbackProgress = voiceMessageState.playbackProgress,
time = voiceMessageState.time,
onPlayClick = onPlayVoiceMessageClick,
onPauseClick = onPauseVoiceMessageClick,
onSeek = onSeekVoiceMessage,
)
is VoiceMessageState.Recording ->
VoiceMessageRecording(
levels = voiceMessageState.levels,
duration = voiceMessageState.duration,
)
VoiceMessageState.Idle -> {}
}
}
val voiceDeleteButton = @Composable {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) })
else -> {}
}
}
if (showTextFormatting && textFormattingOptions != null) {
TextFormattingLayout(
modifier = layoutModifier,
isRoomEncrypted = state.isRoomEncrypted,
textInput = textInput,
dismissTextFormattingButton = {
IconColorButton(
onClick = onDismissTextFormatting,
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(R.string.rich_text_editor_close_formatting_options),
)
},
textFormatting = textFormattingOptions,
endButtonA11y = endButtonA11y,
sendButton = sendButton,
)
} else {
StandardLayout(
voiceMessageState = voiceMessageState,
isRoomEncrypted = state.isRoomEncrypted,
modifier = layoutModifier,
composerOptionsButton = composerOptionsButton,
textInput = textInput,
endButton = sendOrRecordButton,
endButtonA11y = endButtonA11y,
voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton,
)
}
SoftKeyboardEffect(composerMode, onRequestFocus) {
it is MessageComposerMode.Special
}
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion)
if (state is TextEditorState.Rich) {
val menuAction = state.richTextEditorState.menuAction
LaunchedEffect(menuAction) {
if (menuAction is MenuAction.Suggestion) {
val suggestion = Suggestion(menuAction.suggestionPattern)
latestOnReceiveSuggestion(suggestion)
} else {
latestOnReceiveSuggestion(null)
}
}
}
}
@ReadOnlyComposable
@Composable
private fun endButtonA11y(
composerMode: MessageComposerMode,
voiceMessageState: VoiceMessageState,
canSendMessage: Boolean,
): (SemanticsPropertyReceiver) -> Unit {
val a11ySendButtonDescription = stringResource(
id = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> if (voiceMessageState is VoiceMessageState.Recording) {
CommonStrings.a11y_voice_message_stop_recording
} else {
CommonStrings.a11y_voice_message_record
}
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> CommonStrings.common_sending
false -> CommonStrings.action_send_voice_message
}
}
composerMode.isEditing -> CommonStrings.action_send_edited_message
else -> CommonStrings.action_send_message
}
)
val endButtonA11y: (SemanticsPropertyReceiver.() -> Unit) = {
contentDescription = a11ySendButtonDescription
onClick(null, null)
}
return endButtonA11y
}
@Composable
private fun StandardLayout(
voiceMessageState: VoiceMessageState,
isRoomEncrypted: Boolean?,
textInput: @Composable () -> Unit,
composerOptionsButton: @Composable () -> Unit,
voiceRecording: @Composable () -> Unit,
voiceDeleteButton: @Composable () -> Unit,
endButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
if (isRoomEncrypted == false) {
Spacer(Modifier.height(16.dp))
NotEncryptedBadge()
Spacer(Modifier.height(4.dp))
}
Row(verticalAlignment = Alignment.Bottom) {
if (voiceMessageState !is VoiceMessageState.Idle) {
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
Box(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.size(48.dp),
contentAlignment = Alignment.Center,
) {
voiceDeleteButton()
}
} else {
Spacer(modifier = Modifier.width(16.dp))
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
voiceRecording()
}
} else {
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
) {
composerOptionsButton()
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
textInput()
}
}
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp)
.clearAndSetSemantics(endButtonA11y),
contentAlignment = Alignment.Center,
) {
endButton()
}
}
}
}
@Composable
private fun NotEncryptedBadge() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.LockOff(),
contentDescription = null,
tint = ElementTheme.colors.iconInfoPrimary,
)
Spacer(Modifier.width(4.dp))
Text(
text = stringResource(CommonStrings.common_not_encrypted),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
@Composable
private fun TextFormattingLayout(
isRoomEncrypted: Boolean?,
textInput: @Composable () -> Unit,
dismissTextFormattingButton: @Composable () -> Unit,
textFormatting: @Composable () -> Unit,
sendButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (isRoomEncrypted == false) {
NotEncryptedBadge()
Spacer(Modifier.height(8.dp))
}
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
) {
textInput()
}
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(
modifier = Modifier.padding(start = 3.dp)
) {
dismissTextFormattingButton()
}
Box(modifier = Modifier.weight(1f)) {
textFormatting()
}
Box(
modifier = Modifier
.padding(
start = 14.dp,
end = 6.dp,
)
.clearAndSetSemantics(endButtonA11y)
) {
sendButton()
}
}
}
}
@Composable
private fun TextInputBox(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
isTextEmpty: Boolean,
modifier: Modifier = Modifier,
textInput: @Composable () -> Unit,
) {
val bgColor = ElementTheme.colors.bgSubtleSecondary
val borderColor = ElementTheme.colors.borderDisabled
val roundedCorners = textInputRoundedCornerShape(composerMode = composerMode)
Column(
modifier = Modifier
.clip(roundedCorners)
.border(0.5.dp, borderColor, roundedCorners)
.background(color = bgColor)
.requiredHeightIn(min = 42.dp)
.fillMaxSize()
.then(modifier),
) {
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
)
}
Box(
modifier = Modifier
.padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp)
.then(Modifier.testTag(TestTags.textEditor)),
contentAlignment = Alignment.CenterStart,
) {
textInput()
if (isTextEmpty && composerMode.showCaptionCompatibilityWarning()) {
var showBottomSheet by remember { mutableStateOf(false) }
Icon(
modifier = Modifier
.clickable { showBottomSheet = true }
.padding(horizontal = 8.dp, vertical = 4.dp)
.align(Alignment.CenterEnd),
imageVector = CompoundIcons.InfoSolid(),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
if (showBottomSheet) {
CaptionWarningBottomSheet(
onDismiss = { showBottomSheet = false },
)
}
}
}
}
}
private fun aTextEditorStateMarkdownList(isRoomEncrypted: Boolean? = null) = persistentListOf(
aTextEditorStateMarkdown(initialText = "", initialFocus = true, isRoomEncrypted = isRoomEncrypted),
aTextEditorStateMarkdown(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted),
aTextEditorStateMarkdown(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
initialFocus = true,
isRoomEncrypted = isRoomEncrypted,
),
aTextEditorStateMarkdown(initialText = "A message without focus", initialFocus = false, isRoomEncrypted = isRoomEncrypted),
)
private fun aTextEditorStateRichList(isRoomEncrypted: Boolean? = null) = persistentListOf(
aTextEditorStateRich(initialFocus = true, isRoomEncrypted = isRoomEncrypted),
aTextEditorStateRich(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted),
aTextEditorStateRich(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
initialFocus = true,
isRoomEncrypted = isRoomEncrypted,
),
aTextEditorStateRich(initialText = "A message without focus", initialFocus = false, isRoomEncrypted = isRoomEncrypted),
)
@PreviewsDayNight
@Composable
internal fun TextComposerSimplePreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateMarkdownList()
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerSimpleNotEncryptedPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateMarkdownList(isRoomEncrypted = false),
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerFormattingPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerFormattingNotEncryptedPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList(isRoomEncrypted = false)
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerEditPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEdit(),
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerEditNotEncryptedPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList(isRoomEncrypted = false)
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEdit(),
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerEditCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
// Set an existing caption so that the UI will be in edit caption mode
content = "An existing caption",
),
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerAddCaptionPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEditCaption(
// No caption so that the UI will be in add caption mode
content = "",
),
)
}
}
@PreviewsDayNight
@Composable
internal fun MarkdownTextComposerEditPreview() = ElementPreview {
PreviewColumn(
items = aTextEditorStateMarkdownList()
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeEdit(),
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList()
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeReply(
replyToDetails = inReplyToDetails,
),
)
}
}
@Preview(
name = DAY_MODE_NAME,
heightDp = 800,
)
@Preview(
name = NIGHT_MODE_NAME,
uiMode = Configuration.UI_MODE_NIGHT_YES,
heightDp = 800,
)
@Composable
internal fun TextComposerReplyNotEncryptedPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview {
PreviewColumn(
items = aTextEditorStateRichList(isRoomEncrypted = false)
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = aMessageComposerModeReply(
replyToDetails = inReplyToDetails,
),
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerCaptionPreview() = ElementPreview {
val list = aTextEditorStateMarkdownList()
PreviewColumn(
items = list,
) { textEditorState ->
ATextComposer(
state = textEditorState,
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Attachment,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerVoicePreview() = ElementPreview {
PreviewColumn(
items = persistentListOf(
VoiceMessageState.Recording(
duration = 61.seconds,
levels = WaveFormSamples.realisticWaveForm,
),
VoiceMessageState.Preview(
isSending = false,
isPlaying = false,
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 0.seconds,
playbackProgress = 0.0f,
),
VoiceMessageState.Preview(
isSending = false,
isPlaying = true,
showCursor = true,
waveform = WaveFormSamples.realisticWaveForm,
time = 3.seconds,
playbackProgress = 0.2f,
),
VoiceMessageState.Preview(
isSending = true,
isPlaying = false,
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 61.seconds,
playbackProgress = 0.0f,
),
)
) { voiceMessageState ->
ATextComposer(
state = aTextEditorStateRich(initialFocus = true),
voiceMessageState = voiceMessageState,
composerMode = MessageComposerMode.Normal,
)
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview {
PreviewColumn(
items = persistentListOf(
VoiceMessageState.Recording(
duration = 61.seconds,
levels = WaveFormSamples.realisticWaveForm,
),
VoiceMessageState.Preview(
isSending = false,
isPlaying = false,
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 0.seconds,
playbackProgress = 0.0f
),
VoiceMessageState.Preview(
isSending = false,
isPlaying = true,
showCursor = true,
waveform = WaveFormSamples.realisticWaveForm,
time = 3.seconds,
playbackProgress = 0.2f
),
VoiceMessageState.Preview(
isSending = true,
isPlaying = false,
showCursor = false,
waveform = WaveFormSamples.realisticWaveForm,
time = 61.seconds,
playbackProgress = 0.0f
),
)
) { voiceMessageState ->
ATextComposer(
state = aTextEditorStateRich(initialFocus = true, isRoomEncrypted = false),
voiceMessageState = voiceMessageState,
composerMode = MessageComposerMode.Normal,
)
}
}
@Composable
private fun <T> PreviewColumn(
items: ImmutableList<T>,
view: @Composable (T) -> Unit,
) {
Column {
items.forEach { item ->
HorizontalDivider()
Box(
modifier = Modifier.height(IntrinsicSize.Min)
) {
view(item)
}
}
}
}
@Composable
private fun ATextComposer(
state: TextEditorState,
voiceMessageState: VoiceMessageState,
composerMode: MessageComposerMode,
showTextFormatting: Boolean = false,
) {
TextComposer(
state = state,
showTextFormatting = showTextFormatting,
voiceMessageState = voiceMessageState,
composerMode = composerMode,
onRequestFocus = {},
onSendMessage = {},
onResetComposerMode = {},
onAddAttachment = {},
onDismissTextFormatting = {},
onVoiceRecorderEvent = {},
onVoicePlayerEvent = {},
onSendVoiceMessage = {},
onDeleteVoiceMessage = {},
onError = {},
onTyping = {},
onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
resolveAtRoomMentionDisplay = { TextDisplay.Plain },
onSelectRichContent = null,
)
}
fun aMessageComposerModeEdit(
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
content: String = "Some text",
) = MessageComposerMode.Edit(
eventOrTransactionId = eventOrTransactionId,
content = content
)
fun aMessageComposerModeEditCaption(
eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(),
content: String,
) = MessageComposerMode.EditCaption(
eventOrTransactionId = eventOrTransactionId,
content = content,
)
fun aMessageComposerModeReply(
replyToDetails: InReplyToDetails,
hideImage: Boolean = false,
) = MessageComposerMode.Reply(
replyToDetails = replyToDetails,
hideImage = hideImage,
)
@@ -0,0 +1,228 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.view.models.LinkAction
@Composable
fun TextComposerLinkDialog(
onDismissRequest: () -> Unit,
linkAction: LinkAction,
onSaveLinkRequest: (url: String) -> Unit,
onCreateLinkRequest: (url: String, text: String) -> Unit,
onRemoveLinkRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
val urlToEdit by remember(linkAction) {
derivedStateOf {
(linkAction as? LinkAction.SetLink)?.currentUrl
}
}
urlToEdit.let { url ->
when {
url != null -> {
EditLinkDialog(
currentUrl = url,
onDismissRequest = onDismissRequest,
onSaveLinkRequest = onSaveLinkRequest,
onRemoveLinkRequest = onRemoveLinkRequest,
modifier = modifier,
)
}
linkAction is LinkAction.InsertLink -> {
CreateLinkWithTextDialog(
onDismissRequest = onDismissRequest,
onCreateLinkRequest = onCreateLinkRequest,
modifier = modifier,
)
}
linkAction is LinkAction.SetLink -> {
CreateLinkWithoutTextDialog(
onDismissRequest = onDismissRequest,
onSaveLinkRequest = onSaveLinkRequest,
modifier = modifier,
)
}
}
}
}
@Composable
private fun CreateLinkWithTextDialog(
onDismissRequest: () -> Unit,
onCreateLinkRequest: (url: String, text: String) -> Unit,
modifier: Modifier = Modifier,
) {
var linkText by remember { mutableStateOf("") }
var linkUrl by remember { mutableStateOf("") }
val titleText = stringResource(R.string.rich_text_editor_create_link)
fun onSubmit() {
onCreateLinkRequest(linkUrl, linkText)
onDismissRequest()
}
ListDialog(
onDismissRequest = onDismissRequest,
onSubmit = ::onSubmit,
title = titleText,
modifier = modifier
) {
item {
TextFieldListItem(
placeholder = stringResource(id = CommonStrings.common_text),
text = linkText,
onTextChange = { linkText = it },
)
}
item {
TextFieldListItem(
placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder),
text = linkUrl,
onTextChange = { linkUrl = it },
)
}
}
}
@Composable
private fun CreateLinkWithoutTextDialog(
onDismissRequest: () -> Unit,
onSaveLinkRequest: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
var linkUrl by remember { mutableStateOf("") }
val titleText = stringResource(R.string.rich_text_editor_create_link)
fun onSubmit() {
onSaveLinkRequest(linkUrl)
onDismissRequest()
}
ListDialog(
onDismissRequest = onDismissRequest,
onSubmit = ::onSubmit,
title = titleText,
modifier = modifier
) {
item {
TextFieldListItem(
placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder),
text = linkUrl,
onTextChange = { linkUrl = it },
)
}
}
}
// The edit link dialog does not yet support displaying or editing the text of a link
// https://github.com/matrix-org/matrix-rich-text-editor/issues/617
@Composable
private fun EditLinkDialog(
currentUrl: String,
onDismissRequest: () -> Unit,
onSaveLinkRequest: (url: String) -> Unit,
onRemoveLinkRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
var linkUrl by remember { mutableStateOf(currentUrl) }
val titleText = stringResource(R.string.rich_text_editor_edit_link)
fun onSubmit() {
onSaveLinkRequest(linkUrl)
onDismissRequest()
}
fun onRemoveClick() {
onRemoveLinkRequest()
onDismissRequest()
}
ListDialog(
onDismissRequest = onDismissRequest,
onSubmit = ::onSubmit,
title = titleText,
modifier = modifier
) {
item {
TextFieldListItem(
placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder),
text = linkUrl,
onTextChange = { linkUrl = it },
)
}
item {
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.rich_text_editor_remove_link),
color = ElementTheme.colors.textCriticalPrimary
)
},
onClick = ::onRemoveClick,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TextComposerLinkDialogCreateLinkPreview() = ElementPreview {
TextComposerLinkDialog(
onDismissRequest = {},
linkAction = LinkAction.InsertLink,
onSaveLinkRequest = {},
onCreateLinkRequest = { _, _ -> },
onRemoveLinkRequest = {},
)
}
@PreviewsDayNight
@Composable
internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() = ElementPreview {
TextComposerLinkDialog(
onDismissRequest = {},
linkAction = LinkAction.SetLink(null),
onSaveLinkRequest = {},
onCreateLinkRequest = { _, _ -> },
onRemoveLinkRequest = {},
)
}
@PreviewsDayNight
@Composable
internal fun TextComposerLinkDialogEditLinkPreview() = ElementPreview {
TextComposerLinkDialog(
onDismissRequest = {},
linkAction = LinkAction.SetLink("https://element.io"),
onSaveLinkRequest = {},
onCreateLinkRequest = { _, _ -> },
onRemoveLinkRequest = {},
)
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@Composable
internal fun FormattingOption(
state: FormattingOptionState,
toggleable: Boolean,
onClick: () -> Unit,
imageVector: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
) {
val backgroundColor = when (state) {
FormattingOptionState.Selected -> ElementTheme.colors.bgAccentSelected
FormattingOptionState.Default,
FormattingOptionState.Disabled -> Color.Transparent
}
val foregroundColor = when (state) {
FormattingOptionState.Selected -> ElementTheme.colors.iconAccentPrimary
FormattingOptionState.Default -> ElementTheme.colors.iconSecondary
FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled
}
Box(
modifier = modifier
.clickable(
onClick = onClick,
enabled = state != FormattingOptionState.Disabled,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(
bounded = false,
radius = 20.dp,
),
)
.size(48.dp)
.then(
if (toggleable) {
Modifier.toggleable(
value = state == FormattingOptionState.Selected,
enabled = state != FormattingOptionState.Disabled,
onValueChange = { onClick() },
)
} else {
Modifier
}
)
.clearAndSetSemantics {
this.contentDescription = contentDescription
}
) {
Box(
modifier = Modifier
.size(36.dp)
.align(Alignment.Center)
.background(backgroundColor, shape = RoundedCornerShape(8.dp))
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(20.dp),
imageVector = imageVector,
contentDescription = contentDescription,
tint = foregroundColor,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun FormattingOptionPreview() = ElementPreview {
Row {
FormattingOption(
state = FormattingOptionState.Default,
toggleable = false,
onClick = { },
imageVector = CompoundIcons.Bold(),
contentDescription = "",
)
FormattingOption(
state = FormattingOptionState.Selected,
toggleable = true,
onClick = { },
imageVector = CompoundIcons.Italic(),
contentDescription = "",
)
FormattingOption(
state = FormattingOptionState.Disabled,
toggleable = false,
onClick = { },
imageVector = CompoundIcons.Underline(),
contentDescription = "",
)
}
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
internal enum class FormattingOptionState {
Default,
Selected,
Disabled
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.media.drawWaveform
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.lang.Float.min
private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
private val waveFormHeight = 26.dp
@Composable
fun LiveWaveformView(
levels: ImmutableList<Float>,
modifier: Modifier = Modifier,
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
lineWidth: Dp = 2.dp,
linePadding: Dp = 2.dp,
) {
var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) }
var parentWidth by remember { mutableIntStateOf(0) }
val waveformWidth = remember(levels.size, lineWidth, linePadding) {
levels.size * (lineWidth.value + linePadding.value)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = modifier
.fillMaxWidth()
.height(waveFormHeight)
.onSizeChanged { parentWidth = it.width }
) {
Canvas(
modifier = Modifier
.width(Dp(waveformWidth))
.graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA)
.then(modifier)
) {
val width = min(waveformWidth, parentWidth.toFloat())
canvasSize = DpSize(width.dp, size.height.toDp())
val countThatFitsWidth = (parentWidth.toFloat() / (lineWidth.toPx() + linePadding.toPx())).toInt()
drawWaveform(
waveformData = levels.takeLast(countThatFitsWidth).toImmutableList(),
canvasSizePx = Size(canvasSize.width.toPx(), size.height),
brush = brush,
lineWidth = lineWidth,
linePadding = linePadding,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun LiveWaveformViewPreview() = ElementPreview {
Column {
LiveWaveformView(
levels = List(100) { it.toFloat() / 100 }.toImmutableList(),
modifier = Modifier.height(34.dp),
)
LiveWaveformView(
levels = List(40) { it.toFloat() / 40 }.toImmutableList(),
modifier = Modifier.height(34.dp),
)
}
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
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.colors.gradientActionColors
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
/**
* Send button for the message composer.
* Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1956-37575&node-type=frame&m=dev
* Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev
*/
@Composable
internal fun SendButton(
canSendMessage: Boolean,
onClick: () -> Unit,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
enabled = canSendMessage,
) {
val iconVector = when {
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
Box(
modifier = Modifier
.clip(CircleShape)
.size(36.dp)
.buttonBackgroundModifier(canSendMessage)
) {
Icon(
modifier = Modifier
.padding(start = iconStartPadding)
.align(Alignment.Center),
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconPrimary
}
} else {
ElementTheme.colors.iconQuaternary
}
)
}
}
}
@Composable
private fun Modifier.buttonBackgroundModifier(
canSendMessage: Boolean,
) = then(
if (canSendMessage) {
val colors = gradientActionColors()
Modifier.drawWithCache {
val verticalGradientBrush = ShaderBrush(
LinearGradientShader(
from = Offset(0f, 0f),
to = Offset(0f, size.height),
colors = colors,
)
)
onDrawBehind {
drawRect(
brush = verticalGradientBrush,
)
}
}
} else {
Modifier
}
)
@PreviewsDayNight
@Composable
internal fun SendButtonPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "")
Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = true, onClick = {}, composerMode = editMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = editMode)
}
}
@@ -0,0 +1,218 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.R
import io.element.android.libraries.textcomposer.TextComposerLinkDialog
import io.element.android.libraries.textcomposer.model.aRichTextEditorState
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.view.models.InlineFormat
import io.element.android.wysiwyg.view.models.LinkAction
import kotlinx.coroutines.launch
import uniffi.wysiwyg_composer.ActionState
import uniffi.wysiwyg_composer.ComposerAction
@Composable
internal fun TextFormatting(
state: RichTextEditorState,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
fun onInlineFormatClick(inlineFormat: InlineFormat) {
coroutineScope.launch {
state.toggleInlineFormat(inlineFormat)
}
}
fun onToggleListClick(ordered: Boolean) {
coroutineScope.launch {
state.toggleList(ordered)
}
}
fun onIndentClick() {
coroutineScope.launch {
state.indent()
}
}
fun onUnindentClick() {
coroutineScope.launch {
state.unindent()
}
}
fun onCodeBlockClick() {
coroutineScope.launch {
state.toggleCodeBlock()
}
}
fun onQuoteClick() {
coroutineScope.launch {
state.toggleQuote()
}
}
fun onCreateLinkRequest(url: String, text: String) {
coroutineScope.launch {
state.insertLink(url, text)
}
}
fun onSaveLinkRequest(url: String) {
coroutineScope.launch {
state.setLink(url)
}
}
fun onRemoveLinkRequest() {
coroutineScope.launch {
state.removeLink()
}
}
Row(
modifier = modifier
.horizontalScroll(scrollState),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
FormattingOption(
state = state.actions[ComposerAction.BOLD].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.Bold) },
imageVector = CompoundIcons.Bold(),
contentDescription = stringResource(R.string.rich_text_editor_format_bold)
)
FormattingOption(
state = state.actions[ComposerAction.ITALIC].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.Italic) },
imageVector = CompoundIcons.Italic(),
contentDescription = stringResource(R.string.rich_text_editor_format_italic)
)
FormattingOption(
state = state.actions[ComposerAction.UNDERLINE].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.Underline) },
imageVector = CompoundIcons.Underline(),
contentDescription = stringResource(R.string.rich_text_editor_format_underline)
)
FormattingOption(
state = state.actions[ComposerAction.STRIKE_THROUGH].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.StrikeThrough) },
imageVector = CompoundIcons.Strikethrough(),
contentDescription = stringResource(R.string.rich_text_editor_format_strikethrough)
)
var linkDialogAction by remember { mutableStateOf<LinkAction?>(null) }
linkDialogAction?.let {
TextComposerLinkDialog(
onDismissRequest = { linkDialogAction = null },
onCreateLinkRequest = ::onCreateLinkRequest,
onSaveLinkRequest = ::onSaveLinkRequest,
onRemoveLinkRequest = ::onRemoveLinkRequest,
linkAction = it,
)
}
FormattingOption(
state = state.actions[ComposerAction.LINK].toButtonState(),
toggleable = true,
onClick = { linkDialogAction = state.linkAction },
imageVector = CompoundIcons.Link(),
contentDescription = stringResource(R.string.rich_text_editor_link)
)
FormattingOption(
state = state.actions[ComposerAction.UNORDERED_LIST].toButtonState(),
toggleable = true,
onClick = { onToggleListClick(ordered = false) },
imageVector = CompoundIcons.ListBulleted(),
contentDescription = stringResource(R.string.rich_text_editor_bullet_list)
)
FormattingOption(
state = state.actions[ComposerAction.ORDERED_LIST].toButtonState(),
toggleable = true,
onClick = { onToggleListClick(ordered = true) },
imageVector = CompoundIcons.ListNumbered(),
contentDescription = stringResource(R.string.rich_text_editor_numbered_list)
)
FormattingOption(
state = state.actions[ComposerAction.INDENT].toButtonState(),
toggleable = false,
onClick = { onIndentClick() },
imageVector = CompoundIcons.IndentIncrease(),
contentDescription = stringResource(R.string.rich_text_editor_indent)
)
FormattingOption(
state = state.actions[ComposerAction.UNINDENT].toButtonState(),
toggleable = false,
onClick = { onUnindentClick() },
imageVector = CompoundIcons.IndentDecrease(),
contentDescription = stringResource(R.string.rich_text_editor_unindent)
)
FormattingOption(
state = state.actions[ComposerAction.INLINE_CODE].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.InlineCode) },
imageVector = CompoundIcons.InlineCode(),
contentDescription = stringResource(R.string.rich_text_editor_inline_code)
)
FormattingOption(
state = state.actions[ComposerAction.CODE_BLOCK].toButtonState(),
toggleable = true,
onClick = { onCodeBlockClick() },
imageVector = CompoundIcons.Code(),
contentDescription = stringResource(R.string.rich_text_editor_code_block)
)
FormattingOption(
state = state.actions[ComposerAction.QUOTE].toButtonState(),
toggleable = true,
onClick = { onQuoteClick() },
imageVector = CompoundIcons.Quote(),
contentDescription = stringResource(R.string.rich_text_editor_quote)
)
}
}
private fun ActionState?.toButtonState(): FormattingOptionState =
when (this) {
ActionState.ENABLED -> FormattingOptionState.Default
ActionState.REVERSED -> FormattingOptionState.Selected
ActionState.DISABLED, null -> FormattingOptionState.Disabled
}
@PreviewsDayNight
@Composable
internal fun TextFormattingPreview() = ElementPreview {
TextFormatting(state = aRichTextEditorState())
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import io.element.android.libraries.textcomposer.model.MessageComposerMode
@Composable
internal fun textInputRoundedCornerShape(
composerMode: MessageComposerMode,
): RoundedCornerShape {
val roundCornerSmall = 20.dp
val roundCornerLarge = 21.dp
val roundedCornerSize = if (composerMode is MessageComposerMode.Special) {
roundCornerSmall
} else {
roundCornerLarge
}
val roundedCornerSizeState = animateDpAsState(
targetValue = roundedCornerSize,
animationSpec = tween(
durationMillis = 100,
),
label = "roundedCornerSizeAnimation"
)
return RoundedCornerShape(roundedCornerSizeState.value)
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun VoiceMessageDeleteButton(
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier
.size(48.dp),
enabled = enabled,
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(CommonStrings.a11y_delete),
tint = if (enabled) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
}
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageDeleteButtonPreview() = ElementPreview {
Row {
VoiceMessageDeleteButton(
enabled = true,
onClick = {},
)
VoiceMessageDeleteButton(
enabled = false,
onClick = {},
)
}
}
@@ -0,0 +1,209 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.WaveFormSamples
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.formatShort
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun VoiceMessagePreview(
isInteractive: Boolean,
isPlaying: Boolean,
showCursor: Boolean,
waveform: ImmutableList<Float>,
time: Duration,
onPlayClick: () -> Unit,
onPauseClick: () -> Unit,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
playbackProgress: Float = 0f,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = MaterialTheme.shapes.medium,
)
.padding(start = 8.dp, end = 20.dp, top = 6.dp, bottom = 6.dp)
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isPlaying) {
PlayerButton(
type = PlayerButtonType.Pause,
onClick = onPauseClick,
enabled = isInteractive,
)
} else {
PlayerButton(
type = PlayerButtonType.Play,
onClick = onPlayClick,
enabled = isInteractive
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = time.formatShort(),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(12.dp))
WaveformPlaybackView(
modifier = Modifier
.weight(1f)
.height(26.dp),
playbackProgress = playbackProgress,
showCursor = showCursor,
waveform = waveform,
seekEnabled = true,
onSeek = onSeek,
)
}
}
private enum class PlayerButtonType {
Play,
Pause
}
@Composable
private fun PlayerButton(
type: PlayerButtonType,
enabled: Boolean,
onClick: () -> Unit,
) {
IconButton(
onClick = onClick,
modifier = Modifier
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
.size(30.dp),
enabled = enabled,
colors = IconButtonDefaults.iconButtonColors(
contentColor = ElementTheme.colors.iconSecondary,
disabledContentColor = ElementTheme.colors.iconDisabled,
),
) {
when (type) {
PlayerButtonType.Play -> PlayIcon()
PlayerButtonType.Pause -> PauseIcon()
}
}
}
@Composable
private fun PauseIcon() = Icon(
imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(id = CommonStrings.a11y_pause),
modifier = Modifier
.size(20.dp)
.padding(2.dp),
)
@Composable
private fun PlayIcon() = Icon(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
modifier = Modifier
.size(20.dp)
.padding(2.dp),
)
@PreviewsDayNight
@Composable
internal fun VoiceMessagePreviewPreview() = ElementPreview {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AVoiceMessagePreview(
isInteractive = true,
isPlaying = true,
time = 2.seconds,
playbackProgress = 0.2f,
showCursor = true,
waveform = WaveFormSamples.longRealisticWaveForm,
)
AVoiceMessagePreview(
isInteractive = true,
isPlaying = false,
time = 0.seconds,
playbackProgress = 0.0f,
showCursor = true,
waveform = WaveFormSamples.longRealisticWaveForm,
)
AVoiceMessagePreview(
isInteractive = false,
isPlaying = false,
time = 789.seconds,
playbackProgress = 0.0f,
showCursor = false,
waveform = WaveFormSamples.longRealisticWaveForm,
)
}
}
@Composable
private fun AVoiceMessagePreview(
isInteractive: Boolean,
isPlaying: Boolean,
time: Duration,
playbackProgress: Float,
showCursor: Boolean,
waveform: ImmutableList<Float>,
) {
VoiceMessagePreview(
isInteractive = isInteractive,
isPlaying = isPlaying,
time = time,
playbackProgress = playbackProgress,
showCursor = showCursor,
waveform = waveform,
onPlayClick = {},
onPauseClick = {},
onSeek = {},
)
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@Composable
internal fun VoiceMessageRecorderButton(
isRecording: Boolean,
onEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (isRecording) {
StopButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Stop)
}
)
} else {
StartButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Start)
}
)
}
}
@Composable
private fun StartButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier.size(48.dp),
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.MicOn(),
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
}
@Composable
private fun StopButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
) {
Box(
Modifier
.size(36.dp)
.background(
color = ElementTheme.colors.bgActionPrimaryRest,
shape = CircleShape,
)
)
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview {
Row {
VoiceMessageRecorderButton(
isRecording = false,
onEvent = {},
)
VoiceMessageRecorderButton(
isRecording = true,
onEvent = {},
)
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.utils.time.formatShort
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun VoiceMessageRecording(
levels: ImmutableList<Float>,
duration: Duration,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = MaterialTheme.shapes.medium,
)
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RedRecordingDot()
Spacer(Modifier.size(8.dp))
// Timer
Text(
text = duration.formatShort(),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmMedium
)
Spacer(Modifier.size(20.dp))
LiveWaveformView(
modifier = Modifier
.height(26.dp)
.weight(1f),
levels = levels,
)
}
}
@Composable
private fun RedRecordingDot() {
val infiniteTransition = rememberInfiniteTransition("RedRecordingDot")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = InfiniteRepeatableSpec(
animation = TweenSpec(durationMillis = 1_000),
repeatMode = RepeatMode.Reverse,
),
label = "RedRecordingDotAlpha",
)
Box(
modifier = Modifier
.size(8.dp)
.alpha(alpha)
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
)
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageRecordingPreview() = ElementPreview {
VoiceMessageRecording(List(100) { it.toFloat() / 100 }.toImmutableList(), 0.seconds)
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components.markdown
import android.content.Context
import android.view.View
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)
}
}
// When using the EditText within a Compose layout, we need to override focusSearch to prevent the default behavior
// Otherwise it can try searching for focusable nodes in the Compose hierarchy while they're being laid out, which will crash
override fun focusSearch(direction: Int): View? {
return null
}
}
@@ -0,0 +1,195 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.components.markdown
import android.content.ClipData
import android.content.res.ColorStateList
import android.graphics.Color
import android.net.Uri
import android.text.Editable
import android.text.InputType
import android.text.Selection
import android.view.View
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.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.getSpans
import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener
import androidx.core.view.ViewCompat
import androidx.core.view.setPadding
import androidx.core.widget.addTextChangedListener
import io.element.android.compound.theme.ElementTheme
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.LocalMentionSpanUpdater
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.libraries.textcomposer.model.aMarkdownTextEditorState
import io.element.android.wysiwyg.compose.RichTextEditorStyle
import io.element.android.wysiwyg.compose.internal.applyStyleInCompose
@Suppress("ModifierMissing")
@Composable
fun MarkdownTextInput(
state: MarkdownTextEditorState,
placeholder: String,
placeholderColor: androidx.compose.ui.graphics.Color,
onTyping: (Boolean) -> Unit,
onReceiveSuggestion: (Suggestion?) -> Unit,
richTextEditorStyle: RichTextEditorStyle,
onSelectRichContent: ((Uri) -> Unit)?,
) {
// Copied from io.element.android.wysiwyg.internal.utils.UriContentListener
class ReceiveUriContentListener(
private val onContent: (uri: Uri) -> Unit,
) : OnReceiveContentListener {
override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? {
val split = payload.partition { item -> item.uri != null }
val uriContent = split.first
val remaining = split.second
if (uriContent != null) {
val clip: ClipData = uriContent.clip
for (i in 0 until clip.itemCount) {
val uri = clip.getItemAt(i).uri
// ... app-specific logic to handle the URI ...
onContent(uri)
}
}
// Return anything that we didn't handle ourselves. This preserves the default platform
// behavior for text and anything else for which we are not implementing custom handling.
return remaining
}
}
val mentionSpanUpdater = LocalMentionSpanUpdater.current
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)
val text = state.text.value()
setText(text)
setHint(placeholder)
setHintTextColor(ColorStateList.valueOf(placeholderColor.toArgb()))
inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
InputType.TYPE_TEXT_FLAG_MULTI_LINE or
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
val textRange = 0..text.length
setSelection(state.selection.first.coerceIn(textRange), state.selection.last.coerceIn(textRange))
setOnFocusChangeListener { _, hasFocus ->
state.hasFocus = hasFocus
}
addTextChangedListener { editable ->
onTyping(!editable.isNullOrEmpty())
state.text.update(editable, false)
state.lineCount = lineCount
state.currentSuggestion = editable?.checkSuggestionNeeded()
onReceiveSuggestion(state.currentSuggestion)
}
onSelectionChangeListener = { selStart, selEnd ->
state.selection = selStart..selEnd
state.currentSuggestion = editableText.checkSuggestionNeeded()
onReceiveSuggestion(state.currentSuggestion)
}
if (onSelectRichContent != null) {
ViewCompat.setOnReceiveContentListener(
this,
arrayOf("image/*"),
ReceiveUriContentListener { onSelectRichContent(it) }
)
}
state.requestFocusAction = { this.requestFocus() }
}
},
update = { editText ->
editText.applyStyleInCompose(richTextEditorStyle)
val text = state.text.value()
mentionSpanUpdater.updateMentionSpans(text)
if (state.text.needsDisplaying()) {
editText.updateEditableText(text)
state.text.update(editText.editableText, false)
}
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
':' -> SuggestionType.Emoji
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(initialText = "Hello, World!"),
placeholder = "Placeholder",
placeholderColor = ElementTheme.colors.textSecondary,
onTyping = {},
onReceiveSuggestion = {},
richTextEditorStyle = style,
onSelectRichContent = {},
)
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.valueOf(initialText))
private var needsDisplaying by mutableStateOf(false)
fun update(newText: CharSequence?, needsDisplaying: Boolean) {
value = SpannableString.valueOf(newText.orEmpty())
this.needsDisplaying = needsDisplaying
}
fun value(): CharSequence = value
fun needsDisplaying(): Boolean = needsDisplaying
override fun toString(): String {
return "StableCharSequence(value='$value', needsDisplaying=$needsDisplaying)"
}
}
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.text.TextPaint
import android.text.TextUtils
import android.text.style.ReplacementSpan
import androidx.core.text.getSpans
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlin.math.roundToInt
/**
* A span that represents a mention (user, room, etc.) in text.
* @param type The type of mention this span represents.
*/
class MentionSpan(
val type: MentionType,
) : ReplacementSpan() {
private val backgroundPaint = Paint()
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
private var backgroundColor: Int = 0
private var textColor: Int = 0
private var startPadding: Int = 0
private var endPadding: Int = 0
private var typeface: Typeface = Typeface.DEFAULT
private var measuredTextWidth = 0
// The formatted display text, will be set by the formatter
var displayText: CharSequence = ""
private set
/**
* Updates the visual properties of this span.
*/
fun updateTheme(mentionSpanTheme: MentionSpanTheme) {
val isCurrentUser = when (type) {
is MentionType.User -> type.userId == mentionSpanTheme.currentUserId
else -> false
}
backgroundColor = when (type) {
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor
is MentionType.Everyone -> mentionSpanTheme.currentUserBackgroundColor
is MentionType.Room -> mentionSpanTheme.otherBackgroundColor
is MentionType.Message -> mentionSpanTheme.otherBackgroundColor
}
textColor = when (type) {
is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor
is MentionType.Everyone -> mentionSpanTheme.currentUserTextColor
is MentionType.Room -> mentionSpanTheme.otherTextColor
is MentionType.Message -> mentionSpanTheme.otherTextColor
}
val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value
startPadding = startPaddingPx
endPadding = endPaddingPx
typeface = mentionSpanTheme.typeface.value
}
/**
* Updates the display text using a formatter.
*/
fun updateDisplayText(formatter: MentionSpanFormatter) {
displayText = formatter.formatDisplayText(type)
}
override fun getSize(
paint: Paint,
text: CharSequence?,
start: Int,
end: Int,
fm: Paint.FontMetricsInt?
): Int {
textPaint.set(paint)
textPaint.typeface = typeface
// Measure the full text width without truncation
measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt()
return measuredTextWidth + startPadding + endPadding
}
override fun draw(
canvas: Canvas,
text: CharSequence?,
start: Int,
end: Int,
x: Float,
top: Int,
y: Int,
bottom: Int,
paint: Paint
) {
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
val availableWidth = (canvas.width - x).coerceAtLeast(0f)
val measuredWidth = measuredTextWidth + startPadding + endPadding
val pillWidth = minOf(availableWidth, measuredWidth.toFloat())
backgroundPaint.color = backgroundColor
val rect = RectF(x, top.toFloat(), x + pillWidth, y.toFloat() + extraVerticalSpace)
val radius = rect.height() / 2
canvas.drawRoundRect(rect, radius, radius, backgroundPaint)
textPaint.set(paint)
textPaint.color = textColor
textPaint.typeface = typeface
val availableWidthForText = availableWidth - startPadding - endPadding
val textToDraw = if (measuredTextWidth > availableWidthForText) {
TextUtils.ellipsize(
displayText,
textPaint,
availableWidthForText,
TextUtils.TruncateAt.END
)
} else {
displayText
}
canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint)
}
}
/**
* Sealed interface representing different types of mentions.
*/
sealed interface MentionType {
data class User(val userId: UserId) : MentionType
data class Room(val roomIdOrAlias: RoomIdOrAlias) : MentionType
data class Message(val roomIdOrAlias: RoomIdOrAlias, val eventId: EventId) : MentionType
data object Everyone : MentionType
}
/**
* Extension function to get all MentionSpans from a CharSequence.
*/
fun CharSequence.getMentionSpans(start: Int = 0, end: Int = length): List<MentionSpan> {
return if (this is android.text.Spanned) {
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
val customMentionSpans = getSpans<CustomMentionSpan>(start, end)
.map { it.providedSpan }
.filterIsInstance<MentionSpan>()
// Collect all direct mention spans
val directMentionSpans = getSpans<MentionSpan>(start, end)
// Return the union of both
customMentionSpans + directMentionSpans
} else {
emptyList()
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
private const val EVERYONE_DISPLAY_TEXT = "@room"
private const val BUBBLE_ICON = "\uD83D\uDCAC" // 💬
interface MentionSpanFormatter {
fun formatDisplayText(mentionType: MentionType): CharSequence
}
/**
* Formatter for MentionSpan display text.
* This class is responsible for formatting the display text of a MentionSpan
* based on its MentionType and context.
*/
@ContributesBinding(RoomScope::class)
class DefaultMentionSpanFormatter(
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val roomNamesCache: RoomNamesCache,
) : MentionSpanFormatter {
/**
* Format the display text for a mention span.
*
* @param mentionType The type of mention
* @return The formatted display text
*/
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return when (mentionType) {
is MentionType.User -> formatUserMention(mentionType.userId)
is MentionType.Room -> formatRoomMention(mentionType.roomIdOrAlias)
is MentionType.Message -> formatMessageMention(mentionType.roomIdOrAlias)
is MentionType.Everyone -> EVERYONE_DISPLAY_TEXT
}
}
private fun formatUserMention(userId: UserId): String {
// Try to get the display name from cache, fallback to userId
val displayName = roomMemberProfilesCache.getDisplayName(userId)
return if (displayName != null) {
"@$displayName"
} else {
userId.value
}
}
private fun formatRoomMention(roomIdOrAlias: RoomIdOrAlias): String {
val displayName = roomNamesCache.getDisplayName(roomIdOrAlias)
return if (displayName != null) {
"#$displayName"
} else {
roomIdOrAlias.identifier
}
}
private fun formatMessageMention(
roomIdOrAlias: RoomIdOrAlias,
): String {
val roomMention = formatRoomMention(roomIdOrAlias)
return "$BUBBLE_ICON > $roomMention"
}
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
private const val EVERYONE_MENTION_TEXT = "@room"
/**
* Provider for [MentionSpan]s.
*/
@Inject open class MentionSpanProvider(
private val permalinkParser: PermalinkParser,
private val mentionSpanFormatter: MentionSpanFormatter,
private val mentionSpanTheme: MentionSpanTheme,
) {
/**
* Creates a mention span from a text and URL.
*
* @param text The text associated with the mention
* @param url The URL associated with the mention
* @return A mention span if the URL can be parsed as a permalink, null otherwise
*/
fun getMentionSpanFor(text: String, url: String): MentionSpan? {
val permalinkData = permalinkParser.parse(url)
return getMentionSpanFor(text, permalinkData)
}
/**
* Creates a mention span from a text and permalink data.
*
* @param text The text associated with the mention
* @param permalinkData The permalink data associated with the mention
* @return A mention span based on the permalink data, null if the permalink data is not supported
*/
private fun getMentionSpanFor(text: String, permalinkData: PermalinkData): MentionSpan? {
return when (permalinkData) {
is PermalinkData.UserLink -> {
createUserMentionSpan(permalinkData.userId)
}
is PermalinkData.RoomLink -> {
val eventId = permalinkData.eventId
if (eventId != null) {
createMessageMentionSpan(permalinkData.roomIdOrAlias, eventId)
} else {
createRoomMentionSpan(permalinkData.roomIdOrAlias)
}
}
is PermalinkData.FallbackLink -> {
if (text == EVERYONE_MENTION_TEXT) {
createEveryoneMentionSpan()
} else {
null
}
}
else -> null
}
}
/**
* Create a mention span for a user mention.
*
* @param userId The user ID
* @return A mention span for the user
*/
fun createUserMentionSpan(userId: UserId): MentionSpan {
return MentionSpan(type = MentionType.User(userId = userId)).apply {
updateDisplayText(mentionSpanFormatter)
updateTheme(mentionSpanTheme)
}
}
/**
* Create a mention span for a room mention.
*
* @param roomIdOrAlias The room ID or alias
* @return A mention span for the room
*/
fun createRoomMentionSpan(roomIdOrAlias: RoomIdOrAlias): MentionSpan {
return MentionSpan(MentionType.Room(roomIdOrAlias)).apply {
updateDisplayText(mentionSpanFormatter)
updateTheme(mentionSpanTheme)
}
}
/**
* Create a mention span for a message mention.
*
* @param roomIdOrAlias The room ID or alias where the message is located
* @param eventId The event ID of the message
* @return A mention span for the message
*/
fun createMessageMentionSpan(
roomIdOrAlias: RoomIdOrAlias,
eventId: EventId,
): MentionSpan {
return MentionSpan(type = MentionType.Message(roomIdOrAlias, eventId)).apply {
updateTheme(mentionSpanTheme)
updateDisplayText(mentionSpanFormatter)
}
}
/**
* Create a mention span for @room (everyone).
*
* @return A mention span for @room
*/
fun createEveryoneMentionSpan(): MentionSpan {
return MentionSpan(type = MentionType.Everyone).apply {
updateTheme(mentionSpanTheme)
updateDisplayText(mentionSpanFormatter)
}
}
}
@@ -0,0 +1,287 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.text.Spanned
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.buildSpannedString
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.rememberTypeface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.persistentListOf
/**
* Theme used for mention spans.
* To make this work, you need to:
* 1. Call [MentionSpanTheme.updateStyles] so the colors and sizes are computed.
* 2. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.updateTheme] to update the styles of the mention spans.
*/
@Stable
@SingleIn(SessionScope::class)
class MentionSpanTheme(val currentUserId: UserId) {
@Inject
constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId)
internal var currentUserTextColor: Int = 0
internal var currentUserBackgroundColor: Int = Color.WHITE
internal var otherTextColor: Int = 0
internal var otherBackgroundColor: Int = Color.WHITE
private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp)
internal val paddingValuesPx = mutableStateOf(0 to 0)
internal val typeface = mutableStateOf(Typeface.DEFAULT)
/**
* Updates the styles of the mention spans based on the [ElementTheme] and [currentUserId].
*/
@Suppress("ComposableNaming")
@Composable
fun updateStyles() {
currentUserTextColor = ElementTheme.colors.textBadgeAccent.toArgb()
currentUserBackgroundColor = ElementTheme.colors.bgBadgeAccent.toArgb()
otherTextColor = ElementTheme.colors.textPrimary.toArgb()
otherBackgroundColor = ElementTheme.colors.bgBadgeDefault.toArgb()
typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
paddingValuesPx.value = remember(paddingValues, density, layoutDirection) {
with(density) {
val leftPadding = paddingValues.calculateLeftPadding(layoutDirection).roundToPx()
val rightPadding = paddingValues.calculateRightPadding(layoutDirection).roundToPx()
leftPadding to rightPadding
}
}
}
}
/**
* Updates the styles of the mention spans in the given [CharSequence].
*/
fun MentionSpanTheme.updateMentionStyles(charSequence: CharSequence) {
val spanned = charSequence as? Spanned ?: return
val mentionSpans = spanned.getMentionSpans()
for (span in mentionSpans) {
span.updateTheme(this)
}
}
@PreviewsDayNight
@Composable
internal fun MentionSpanThemePreview() {
ElementPreview {
val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) }
val provider = remember {
MentionSpanProvider(
mentionSpanTheme = mentionSpanTheme,
mentionSpanFormatter = object : MentionSpanFormatter {
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return when (mentionType) {
is MentionType.User -> mentionType.userId.value
is MentionType.Room -> mentionType.roomIdOrAlias.identifier
is MentionType.Message -> "\uD83D\uDCAC > ${mentionType.roomIdOrAlias.identifier}"
is MentionType.Everyone -> "@room"
}
}
},
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return when (uriString) {
"https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org"))
"https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org"))
"https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(),
eventId = null,
viaParameters = persistentListOf(),
)
"@room" -> PermalinkData.FallbackLink(Uri.EMPTY, false)
else -> throw AssertionError("Unexpected value $uriString")
}
}
},
)
}
val textColor = ElementTheme.colors.textPrimary.toArgb()
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:matrix.org", "https://matrix.to/#/#room:matrix.org")
fun mentionSpanEveryone() = provider.createEveryoneMentionSpan()
mentionSpanTheme.updateStyles()
AndroidView(factory = { context ->
TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString {
append("This is a ")
append("@mention", mentionSpanMe(), 0)
append(" to the current user and this is a ")
append("@mention", mentionSpanOther(), 0)
append(" to other user. This is for everyone in the ")
append("@room", mentionSpanEveryone(), 0)
append(". This one is for a link to another room: ")
append("#room:matrix.org", mentionSpanRoom(), 0)
append("\n\n")
append("This ")
append("mention", mentionSpanMe(), 0)
append(" didn't have an '@' and it was automatically added, same as this ")
append("room:matrix.org", mentionSpanRoom(), 0)
append(" one, which had no leading '#'.")
}
setTextColor(textColor)
}
})
}
}
@Composable
private fun MentionSpanThemeInTimelineContent(
bgColor: Int,
modifier: Modifier = Modifier,
) {
val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) }
val provider = remember {
MentionSpanProvider(
mentionSpanTheme = mentionSpanTheme,
mentionSpanFormatter = object : MentionSpanFormatter {
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return when (mentionType) {
is MentionType.User -> mentionType.userId.value
else -> throw AssertionError("Unexpected value $mentionType")
}
}
},
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return when (uriString) {
"https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org"))
"https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org"))
else -> throw AssertionError("Unexpected value $uriString")
}
}
},
)
}
val textColor = ElementTheme.colors.textPrimary.toArgb()
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org")
mentionSpanTheme.updateStyles()
AndroidView(
modifier = modifier,
factory = { context ->
TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString {
append("Hello ")
append("@mention", mentionSpanMe(), 0)
append(" ")
append("@mention", mentionSpanOther(), 0)
}
setTextColor(textColor)
setBackgroundColor(bgColor)
}
}
)
}
@PreviewsDayNight
@Composable
internal fun MentionSpanThemeInTimelinePreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Message from me
Text(
text = "Message from me",
style = ElementTheme.typography.fontBodySmMedium,
)
ElementTheme.colors.messageFromMeBackground.let { color ->
MentionSpanThemeInTimelineContent(
modifier = Modifier
.padding(start = 60.dp, end = 8.dp)
.background(
color = color,
shape = RoundedCornerShape(12.dp),
)
.padding(8.dp),
bgColor = color.toArgb()
)
}
// Message from other
ElementTheme.colors.messageFromOtherBackground.let { color ->
Text(
text = "Message from other",
style = ElementTheme.typography.fontBodySmMedium,
)
MentionSpanThemeInTimelineContent(
modifier = Modifier
.padding(start = 8.dp, end = 60.dp)
.padding(4.dp)
.background(
color = color,
shape = RoundedCornerShape(12.dp)
)
.padding(8.dp),
bgColor = color.toArgb()
)
}
// Composer
ElementTheme.colors.bgSubtleSecondary.let { color ->
Text(
text = "Composer",
style = ElementTheme.typography.fontBodySmMedium,
)
MentionSpanThemeInTimelineContent(
modifier = Modifier
.padding(start = 4.dp, end = 4.dp)
.background(color)
.padding(8.dp),
bgColor = color.toArgb()
)
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
interface MentionSpanUpdater {
fun updateMentionSpans(text: CharSequence): CharSequence
@Composable
fun rememberMentionSpans(text: CharSequence): CharSequence
}
@ContributesBinding(RoomScope::class)
class DefaultMentionSpanUpdater(
private val formatter: MentionSpanFormatter,
private val theme: MentionSpanTheme,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
private val roomNamesCache: RoomNamesCache,
) : MentionSpanUpdater {
@Composable
override fun rememberMentionSpans(text: CharSequence): CharSequence {
val isLightTheme = ElementTheme.isLightTheme
val roomInfoCacheUpdate by roomNamesCache.updateFlow.collectAsState(0)
val roomMemberProfilesCacheUpdate by roomMemberProfilesCache.updateFlow.collectAsState(0)
return remember(text, roomInfoCacheUpdate, roomMemberProfilesCacheUpdate, isLightTheme) {
updateMentionSpans(text)
text
}
}
override fun updateMentionSpans(text: CharSequence): CharSequence {
for (mentionSpan in text.getMentionSpans()) {
mentionSpan.updateTheme(theme)
mentionSpan.updateDisplayText(formatter)
}
return text
}
}
private object NoOpMentionSpanUpdater : MentionSpanUpdater {
override fun updateMentionSpans(text: CharSequence): CharSequence {
return text
}
@Composable
override fun rememberMentionSpans(text: CharSequence): CharSequence {
return text
}
}
val LocalMentionSpanUpdater = staticCompositionLocalOf<MentionSpanUpdater> { NoOpMentionSpanUpdater }
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.mentions
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
@Immutable
sealed interface ResolvedSuggestion {
data object AtRoom : ResolvedSuggestion
data class Member(val roomMember: RoomMember) : ResolvedSuggestion
data class Alias(
val roomAlias: RoomAlias,
val roomId: RoomId,
val roomName: String?,
val roomAvatarUrl: String?,
) : ResolvedSuggestion {
fun getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
name = roomName,
url = roomAvatarUrl,
size = size,
)
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
import io.element.android.wysiwyg.compose.RichTextEditorState
fun aTextEditorStateMarkdown(
initialText: String? = "",
initialFocus: Boolean = false,
isRoomEncrypted: Boolean? = null,
): TextEditorState {
return TextEditorState.Markdown(
aMarkdownTextEditorState(
initialText = initialText,
initialFocus = initialFocus,
),
isRoomEncrypted = isRoomEncrypted,
)
}
fun aMarkdownTextEditorState(
initialText: String? = "",
initialFocus: Boolean = false,
): MarkdownTextEditorState {
return MarkdownTextEditorState(
initialText = initialText,
initialFocus = initialFocus,
)
}
fun aTextEditorStateRich(
initialText: String = "",
initialHtml: String = initialText,
initialMarkdown: String = initialText,
initialFocus: Boolean = false,
isRoomEncrypted: Boolean? = null,
): TextEditorState {
return TextEditorState.Rich(
aRichTextEditorState(
initialText = initialText,
initialHtml = initialHtml,
initialMarkdown = initialMarkdown,
initialFocus = initialFocus,
),
isRoomEncrypted = isRoomEncrypted,
)
}
fun aRichTextEditorState(
initialText: String = "",
initialHtml: String = initialText,
initialMarkdown: String = initialText,
initialFocus: Boolean = false,
): RichTextEditorState {
return RichTextEditorState(
initialHtml = initialHtml,
initialMarkdown = initialMarkdown,
initialFocus = initialFocus,
)
}
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
import android.os.Parcelable
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import kotlinx.parcelize.Parcelize
@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 currentSuggestion by mutableStateOf<Suggestion?>(null)
fun insertSuggestion(
resolvedSuggestion: ResolvedSuggestion,
mentionSpanProvider: MentionSpanProvider,
) {
val suggestion = currentSuggestion ?: return
when (resolvedSuggestion) {
is ResolvedSuggestion.AtRoom -> {
val currentText = SpannableStringBuilder(text.value())
val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan()
currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value())
val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId)
currentText.replace(suggestion.start, suggestion.end, "@ ")
val end = suggestion.start + 1
currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
}
is ResolvedSuggestion.Alias -> {
val currentText = SpannableStringBuilder(text.value())
val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias())
currentText.replace(suggestion.start, suggestion.end, "# ")
val end = suggestion.start + 1
currentText.setSpan(mentionSpan, 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.getMentionSpans()
buildString {
append(charSequence.toString())
if (mentions.isNotEmpty()) {
for (mention in mentions.sortedByDescending { charSequence.getSpanEnd(it) }) {
val start = charSequence.getSpanStart(mention)
val end = charSequence.getSpanEnd(mention)
when (mention.type) {
is MentionType.User -> {
permalinkBuilder.permalinkForUser(mention.type.userId).getOrNull()?.let { link ->
replace(start, end, "[${mention.type.userId}]($link)")
}
}
is MentionType.Everyone -> {
replace(start, end, "@room")
}
is MentionType.Room -> {
val roomIdOrAlias = mention.type.roomIdOrAlias
if (roomIdOrAlias is RoomIdOrAlias.Alias) {
permalinkBuilder.permalinkForRoomAlias(roomIdOrAlias.roomAlias).getOrNull()?.let { link ->
replace(start, end, "[${roomIdOrAlias.roomAlias}]($link)")
}
}
}
else -> Unit
}
}
}
}
} else {
charSequence.toString()
}
}
fun getMentions(): List<IntentionalMention> {
val mentionSpans = text.value().getMentionSpans()
return mentionSpans.mapNotNull { mentionSpan ->
when (mentionSpan.type) {
is MentionType.User -> IntentionalMention.User(mentionSpan.type.userId)
is MentionType.Everyone -> IntentionalMention.Room
else -> null
}
}
}
@Parcelize
data class SavedValue(
val text: CharSequence,
val selectionStart: Int,
val selectionEnd: Int,
) : Parcelable
}
object MarkdownTextEditorStateSaver : Saver<MarkdownTextEditorState, MarkdownTextEditorState.SavedValue> {
override fun restore(value: MarkdownTextEditorState.SavedValue): MarkdownTextEditorState {
return MarkdownTextEditorState(
initialText = "",
initialFocus = false,
).apply {
text.update(value.text, true)
selection = value.selectionStart..value.selectionEnd
}
}
override fun SaverScope.save(value: MarkdownTextEditorState): MarkdownTextEditorState.SavedValue {
return MarkdownTextEditorState.SavedValue(
text = value.text.value(),
selectionStart = value.selection.first,
selectionEnd = value.selection.last,
)
}
}
@Composable
fun rememberMarkdownTextEditorState(
initialText: String? = null,
initialFocus: Boolean = false,
): MarkdownTextEditorState {
return rememberSaveable(saver = MarkdownTextEditorStateSaver) { MarkdownTextEditorState(initialText, initialFocus) }
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
import io.element.android.libraries.matrix.api.room.IntentionalMention
data class Message(
val html: String?,
val markdown: String,
val intentionalMentions: List<IntentionalMention>,
)
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.eventId
@Immutable
sealed interface MessageComposerMode {
data object Normal : MessageComposerMode
data object Attachment : MessageComposerMode
sealed interface Special : MessageComposerMode
data class Edit(
val eventOrTransactionId: EventOrTransactionId,
val content: String
) : Special
data class EditCaption(
val eventOrTransactionId: EventOrTransactionId,
val content: String,
) : Special
data class Reply(
val replyToDetails: InReplyToDetails,
val hideImage: Boolean,
) : Special {
val eventId: EventId = replyToDetails.eventId()
}
val isEditing: Boolean
get() = this is Edit || this is EditCaption
val isReply: Boolean
get() = this is Reply
val inThread: Boolean
get() = this is Reply &&
replyToDetails is InReplyToDetails.Ready &&
replyToDetails.eventContent is MessageContent &&
(replyToDetails.eventContent as MessageContent).threadInfo is EventThreadInfo.ThreadResponse
}
fun MessageComposerMode.showCaptionCompatibilityWarning(): Boolean {
return when (this) {
is MessageComposerMode.Attachment -> true
is MessageComposerMode.EditCaption -> content.isEmpty()
else -> false
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
import uniffi.wysiwyg_composer.PatternKey
import uniffi.wysiwyg_composer.SuggestionPattern
data class Suggestion(
val start: Int,
val end: Int,
val type: SuggestionType,
val text: String,
) {
constructor(suggestion: SuggestionPattern) : this(
suggestion.start.toInt(),
suggestion.end.toInt(),
SuggestionType.fromPatternKey(suggestion.key),
suggestion.text,
)
}
sealed interface SuggestionType {
data object Mention : SuggestionType
data object Command : SuggestionType
data object Room : SuggestionType
data object Emoji : SuggestionType
data class Custom(val pattern: String) : SuggestionType
companion object {
fun fromPatternKey(key: PatternKey): SuggestionType {
return when (key) {
PatternKey.At -> Mention
PatternKey.Slash -> Command
PatternKey.Hash -> Room
PatternKey.Colon -> Emoji
is PatternKey.Custom -> Custom(key.v1)
}
}
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 {
val isRoomEncrypted: Boolean?
data class Markdown(
val state: MarkdownTextEditorState,
override val isRoomEncrypted: Boolean?,
) : TextEditorState
data class Rich(
val richTextEditorState: RichTextEditorState,
override val isRoomEncrypted: Boolean?,
) : 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
}
// Note: for test only
suspend fun setHtml(html: String) {
when (this) {
is Markdown -> Unit
is Rich -> richTextEditorState.setHtml(html)
}
}
// Note: for test only
suspend fun setMarkdown(text: String) {
when (this) {
is Markdown -> state.text.update(text, true)
is Rich -> richTextEditorState.setMarkdown(text)
}
}
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,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
sealed interface VoiceMessagePlayerEvent {
data object Play : VoiceMessagePlayerEvent
data object Pause : VoiceMessagePlayerEvent
data class Seek(
val position: Float
) : VoiceMessagePlayerEvent
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
sealed interface VoiceMessageRecorderEvent {
data object Start : VoiceMessageRecorderEvent
data object Stop : VoiceMessageRecorderEvent
data object Cancel : VoiceMessageRecorderEvent
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.model
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlin.time.Duration
@Immutable
sealed interface VoiceMessageState {
data object Idle : VoiceMessageState
data class Preview(
val isSending: Boolean,
val isPlaying: Boolean,
val showCursor: Boolean,
val playbackProgress: Float,
val time: Duration,
// Values are between 0 and 1
val waveform: ImmutableList<Float>,
) : VoiceMessageState
data class Recording(
val duration: Duration,
// Values are between 0 and 1
val levels: ImmutableList<Float>,
) : VoiceMessageState
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Дадаць далучэнне"</string>
<string name="rich_text_editor_bullet_list">"Пераключыць маркіраваны спіс"</string>
<string name="rich_text_editor_close_formatting_options">"Закрыць параметры фарматавання"</string>
<string name="rich_text_editor_code_block">"Пераключыць блок кода"</string>
<string name="rich_text_editor_composer_placeholder">"Паведамленне…"</string>
<string name="rich_text_editor_create_link">"Стварыць спасылку"</string>
<string name="rich_text_editor_edit_link">"Рэдагаваць спасылку"</string>
<string name="rich_text_editor_format_bold">"Ужыць тоўсты шрыфт"</string>
<string name="rich_text_editor_format_italic">"Ужыць курсіўны фармат"</string>
<string name="rich_text_editor_format_strikethrough">"Ужыць фармат закрэслівання"</string>
<string name="rich_text_editor_format_underline">"Ужыць фармат падкрэслення"</string>
<string name="rich_text_editor_full_screen_toggle">"Пераключэнне поўнаэкраннага рэжыму"</string>
<string name="rich_text_editor_indent">"Водступ"</string>
<string name="rich_text_editor_inline_code">"Ужыць убудаваны фармат кода"</string>
<string name="rich_text_editor_link">"Усталяваць спасылку"</string>
<string name="rich_text_editor_numbered_list">"Пераключыць нумараваны спіс"</string>
<string name="rich_text_editor_open_compose_options">"Адкрыйце параметры кампазіцыі"</string>
<string name="rich_text_editor_quote">"Пераключыць цытату"</string>
<string name="rich_text_editor_remove_link">"Выдаліць спасылку"</string>
<string name="rich_text_editor_unindent">"Без водступу"</string>
<string name="rich_text_editor_url_placeholder">"Спасылка"</string>
<string name="screen_room_voice_message_tooltip">"Утрымлівайце для запісу"</string>
</resources>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Прикачване на файл"</string>
<string name="rich_text_editor_close_formatting_options">"Отказ и затваряне на форматирането на текст"</string>
<string name="rich_text_editor_code_block">"Превключване на кодов блок"</string>
<string name="rich_text_editor_composer_placeholder">"Съобщение…"</string>
<string name="rich_text_editor_create_link">"Създаване на връзка"</string>
<string name="rich_text_editor_edit_link">"Редактиране на връзката"</string>
<string name="rich_text_editor_format_bold">"Прилагане на удебелен формат"</string>
<string name="rich_text_editor_format_italic">"Прилагане на курсив формат"</string>
<string name="rich_text_editor_format_strikethrough">"Прилагане на зачеркнат формат"</string>
<string name="rich_text_editor_format_underline">"Прилагане на формат за подчертаване"</string>
<string name="rich_text_editor_full_screen_toggle">"Превключване на режим на цял екран"</string>
<string name="rich_text_editor_indent">"Отстъп навътре"</string>
<string name="rich_text_editor_inline_code">"Прилагане на формат на вграден код"</string>
<string name="rich_text_editor_link">"Задаване на връзка"</string>
<string name="rich_text_editor_numbered_list">"Превключване на номериран списък"</string>
<string name="rich_text_editor_open_compose_options">"Отваряне на опциите за съставяне"</string>
<string name="rich_text_editor_quote">"Превключване на цитат"</string>
<string name="rich_text_editor_remove_link">"Премахване на връзката"</string>
<string name="rich_text_editor_unindent">"Отстъп навън"</string>
<string name="rich_text_editor_url_placeholder">"Връзка"</string>
<string name="screen_room_voice_message_tooltip">"Задръжте, за записване"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Přidat přílohu"</string>
<string name="rich_text_editor_bullet_list">"Přepnout seznam s odrážkami"</string>
<string name="rich_text_editor_close_formatting_options">"Zrušit a zavřít formátování textu"</string>
<string name="rich_text_editor_code_block">"Přepnout blok kódu"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Volitelný titulek…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Šifrovaná zpráva…"</string>
<string name="rich_text_editor_composer_placeholder">"Zpráva…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Nešifrovaná zpráva…"</string>
<string name="rich_text_editor_create_link">"Vytvořit odkaz"</string>
<string name="rich_text_editor_edit_link">"Upravit odkaz"</string>
<string name="rich_text_editor_format_action">"%1$s, stav: %2$s"</string>
<string name="rich_text_editor_format_bold">"Použít tučný text"</string>
<string name="rich_text_editor_format_italic">"Použít kurzívu"</string>
<string name="rich_text_editor_format_state_disabled">"zakázáno"</string>
<string name="rich_text_editor_format_state_off">"VYP"</string>
<string name="rich_text_editor_format_state_on">"ZAP"</string>
<string name="rich_text_editor_format_strikethrough">"Použít přeškrtnutí"</string>
<string name="rich_text_editor_format_underline">"Použít podtržení"</string>
<string name="rich_text_editor_full_screen_toggle">"Přepnout režim celé obrazovky"</string>
<string name="rich_text_editor_indent">"Odsazení"</string>
<string name="rich_text_editor_inline_code">"Použít formát inline kódu"</string>
<string name="rich_text_editor_link">"Nastavit odkaz"</string>
<string name="rich_text_editor_numbered_list">"Přepnout číslovaný seznam"</string>
<string name="rich_text_editor_open_compose_options">"Otevřít možnosti psaní"</string>
<string name="rich_text_editor_quote">"Přepnout citaci"</string>
<string name="rich_text_editor_remove_link">"Odstranit odkaz"</string>
<string name="rich_text_editor_unindent">"Zrušit odsazení"</string>
<string name="rich_text_editor_url_placeholder">"Odkaz"</string>
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
<string name="screen_room_voice_message_tooltip">"Držte pro nahrávání"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Ychwanegu atodiad"</string>
<string name="rich_text_editor_bullet_list">"Toglo\'r rhestr fwledi"</string>
<string name="rich_text_editor_close_formatting_options">"Cau\'r dewisiadau fformatio"</string>
<string name="rich_text_editor_code_block">"Toglo\'r bloc cod"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Ychwanegu capsiwn"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Neges wedi\'i hamgryptio…"</string>
<string name="rich_text_editor_composer_placeholder">"Neges…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Neges heb ei hamgryptio…"</string>
<string name="rich_text_editor_create_link">"Creu dolen"</string>
<string name="rich_text_editor_edit_link">"Golygu dolen"</string>
<string name="rich_text_editor_format_action">"%1$s, cyflwr: %2$s"</string>
<string name="rich_text_editor_format_bold">"Gosod fformat trwm"</string>
<string name="rich_text_editor_format_italic">"Gosod fformat italig"</string>
<string name="rich_text_editor_format_state_disabled">"analluogwyd"</string>
<string name="rich_text_editor_format_state_off">"i ffwrdd"</string>
<string name="rich_text_editor_format_state_on">"ymlaen"</string>
<string name="rich_text_editor_format_strikethrough">"Gosod fformat llinell trwodd"</string>
<string name="rich_text_editor_format_underline">"Gosod fformat tanlinellu"</string>
<string name="rich_text_editor_full_screen_toggle">"Toglo\'r modd sgrin lawn"</string>
<string name="rich_text_editor_indent">"Mewnoliad"</string>
<string name="rich_text_editor_inline_code">"Gosod fformat cod mewnlin"</string>
<string name="rich_text_editor_link">"Gosod dolen"</string>
<string name="rich_text_editor_numbered_list">"Toglo\'r rhestr wedi\'i rhifo"</string>
<string name="rich_text_editor_open_compose_options">"Agor y dewisiadau cyfansoddi"</string>
<string name="rich_text_editor_quote">"Toglo\'r dyfyniad"</string>
<string name="rich_text_editor_remove_link">"Dileu dolen"</string>
<string name="rich_text_editor_unindent">"Dadmewnoli"</string>
<string name="rich_text_editor_url_placeholder">"Dolen"</string>
<string name="screen_media_upload_preview_caption_warning">"Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn."</string>
<string name="screen_room_voice_message_tooltip">"Daliwch i recordio"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Tilføj vedhæftet fil"</string>
<string name="rich_text_editor_bullet_list">"Slå punktopstilling til/fra"</string>
<string name="rich_text_editor_close_formatting_options">"Annullér og luk tekstformatering"</string>
<string name="rich_text_editor_code_block">"Slå kodeblok til/fra"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Tilføj en billedtekst"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Krypteret besked…"</string>
<string name="rich_text_editor_composer_placeholder">"Besked…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Ukrypteret besked…"</string>
<string name="rich_text_editor_create_link">"Opret et link"</string>
<string name="rich_text_editor_edit_link">"Rediger link"</string>
<string name="rich_text_editor_format_action">"%1$s, tilstand: %2$s"</string>
<string name="rich_text_editor_format_bold">"Anvend fed skrift"</string>
<string name="rich_text_editor_format_italic">"Anvend kursiv"</string>
<string name="rich_text_editor_format_state_disabled">"deaktiveret"</string>
<string name="rich_text_editor_format_state_off">"slukket"</string>
<string name="rich_text_editor_format_state_on">"aktiv"</string>
<string name="rich_text_editor_format_strikethrough">"Anvend gennemstregning"</string>
<string name="rich_text_editor_format_underline">"Anvend understregning"</string>
<string name="rich_text_editor_full_screen_toggle">"Slå fuldskærmsvisning til/fra"</string>
<string name="rich_text_editor_indent">"Indrykning"</string>
<string name="rich_text_editor_inline_code">"Anvend inline kodeformat"</string>
<string name="rich_text_editor_link">"Indstil link"</string>
<string name="rich_text_editor_numbered_list">"Slå nummereret liste til/fra"</string>
<string name="rich_text_editor_open_compose_options">"Åbn skriveindstillinger"</string>
<string name="rich_text_editor_quote">"Slå citation til/fra"</string>
<string name="rich_text_editor_remove_link">"Fjern link"</string>
<string name="rich_text_editor_unindent">"Fjern indrykning"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_media_upload_preview_caption_warning">"Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps."</string>
<string name="screen_room_voice_message_tooltip">"Hold nede for at optage"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Anhang hinzufügen"</string>
<string name="rich_text_editor_bullet_list">"Aufzählungsliste umschalten"</string>
<string name="rich_text_editor_close_formatting_options">"Textformatierung abbrechen und schließen"</string>
<string name="rich_text_editor_code_block">"Codeblock umschalten"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Bildunterschrift hinzufügen"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Verschlüsselte Nachricht…"</string>
<string name="rich_text_editor_composer_placeholder">"Nachricht…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Unverschlüsselte Nachricht"</string>
<string name="rich_text_editor_create_link">"Einen Link erstellen"</string>
<string name="rich_text_editor_edit_link">"Link bearbeiten"</string>
<string name="rich_text_editor_format_action">"%1$s, Zustand: %2$s"</string>
<string name="rich_text_editor_format_bold">"Fettes Format anwenden"</string>
<string name="rich_text_editor_format_italic">"Kursives Format anwenden"</string>
<string name="rich_text_editor_format_state_disabled">"deaktiviert"</string>
<string name="rich_text_editor_format_state_off">"aus"</string>
<string name="rich_text_editor_format_state_on">"ein"</string>
<string name="rich_text_editor_format_strikethrough">"Text durchstreichen"</string>
<string name="rich_text_editor_format_underline">"Unterstreichungsformat anwenden"</string>
<string name="rich_text_editor_full_screen_toggle">"Vollbildmodus umschalten"</string>
<string name="rich_text_editor_indent">"Einrückung"</string>
<string name="rich_text_editor_inline_code">"Inline-Codeformat anwenden"</string>
<string name="rich_text_editor_link">"Link setzen"</string>
<string name="rich_text_editor_numbered_list">"Nummerierte Liste umschalten"</string>
<string name="rich_text_editor_open_compose_options">"Optionen zum Verfassen öffnen"</string>
<string name="rich_text_editor_quote">"Vorschlag umschalten"</string>
<string name="rich_text_editor_remove_link">"Link entfernen"</string>
<string name="rich_text_editor_unindent">"Ohne Einrückung"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_media_upload_preview_caption_warning">"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."</string>
<string name="screen_room_voice_message_tooltip">"Zum Aufnehmen gedrückt halten"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Προσθήκη συνημμένου"</string>
<string name="rich_text_editor_bullet_list">"Διακόπτης λίστας κουκκίδων"</string>
<string name="rich_text_editor_close_formatting_options">"Κλείσε τις επιλογές μορφοποίησης"</string>
<string name="rich_text_editor_code_block">"Διακόπτης μπλοκ κώδικα"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Προαιρετική λεζάντα…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Κρυπτογραφημένο μήνυμα…"</string>
<string name="rich_text_editor_composer_placeholder">"Μήνυμα…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Μη κρυπτογραφημένο μήνυμα…"</string>
<string name="rich_text_editor_create_link">"Δημιούργησε έναν σύνδεσμο"</string>
<string name="rich_text_editor_edit_link">"Επεξεργασία συνδέσμου"</string>
<string name="rich_text_editor_format_action">"%1$s, κατάσταση: %2$s"</string>
<string name="rich_text_editor_format_bold">"Εφαρμογή έντονης μορφής"</string>
<string name="rich_text_editor_format_italic">"Εφαρμογή πλάγιας μορφής"</string>
<string name="rich_text_editor_format_state_disabled">"ανενεργό"</string>
<string name="rich_text_editor_format_state_off">"κλειστό"</string>
<string name="rich_text_editor_format_state_on">"ενεργό"</string>
<string name="rich_text_editor_format_strikethrough">"Εφαρμογή μορφής διαγραφής"</string>
<string name="rich_text_editor_format_underline">"Εφαρμογή μορφής υπογράμμισης"</string>
<string name="rich_text_editor_full_screen_toggle">"Εναλλαγή λειτουργίας πλήρους οθόνης"</string>
<string name="rich_text_editor_indent">"Εσοχή"</string>
<string name="rich_text_editor_inline_code">"Εφαρμογή ενσωματωμένης μορφής κώδικα"</string>
<string name="rich_text_editor_link">"Ορισμός συνδέσμου"</string>
<string name="rich_text_editor_numbered_list">"Διακόπτης αριθμημένης λίστας"</string>
<string name="rich_text_editor_open_compose_options">"Άνοιξε τις επιλογές σύνθεσης"</string>
<string name="rich_text_editor_quote">"Διακόπτης παράθεσης"</string>
<string name="rich_text_editor_remove_link">"Κατάργηση συνδέσμου"</string>
<string name="rich_text_editor_unindent">"Χωρίς εσοχή"</string>
<string name="rich_text_editor_url_placeholder">"Σύνδεσμος"</string>
<string name="screen_media_upload_preview_caption_warning">"Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές."</string>
<string name="screen_room_voice_message_tooltip">"Κράτα για εγγραφή"</string>
</resources>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Adjuntar archivo"</string>
<string name="rich_text_editor_bullet_list">"Lista de puntos"</string>
<string name="rich_text_editor_close_formatting_options">"Cerrar opciones de formato"</string>
<string name="rich_text_editor_code_block">"Bloque de código"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Agregar una leyenda"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Mensaje cifrado…"</string>
<string name="rich_text_editor_composer_placeholder">"Mensaje…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Mensaje no cifrado…"</string>
<string name="rich_text_editor_create_link">"Crear un enlace"</string>
<string name="rich_text_editor_edit_link">"Editar enlace"</string>
<string name="rich_text_editor_format_bold">"Aplicar formato negrita"</string>
<string name="rich_text_editor_format_italic">"Aplicar formato cursiva"</string>
<string name="rich_text_editor_format_strikethrough">"Aplicar formato tachado"</string>
<string name="rich_text_editor_format_underline">"Aplicar formato de subrayado"</string>
<string name="rich_text_editor_full_screen_toggle">"Pantalla completa"</string>
<string name="rich_text_editor_indent">"Añadir sangría"</string>
<string name="rich_text_editor_inline_code">"Código"</string>
<string name="rich_text_editor_link">"Enlazar"</string>
<string name="rich_text_editor_numbered_list">"Lista numérica"</string>
<string name="rich_text_editor_open_compose_options">"Abrir opciones de formato"</string>
<string name="rich_text_editor_quote">"Cita"</string>
<string name="rich_text_editor_remove_link">"Eliminar enlace"</string>
<string name="rich_text_editor_unindent">"Quitar sangría"</string>
<string name="rich_text_editor_url_placeholder">"Enlace"</string>
<string name="screen_media_upload_preview_caption_warning">"Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas."</string>
<string name="screen_room_voice_message_tooltip">"Mantén pulsado para grabar"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Lisa manus"</string>
<string name="rich_text_editor_bullet_list">"Lülita mummudega loend sisse/välja"</string>
<string name="rich_text_editor_close_formatting_options">"Katkesta ja sulge tekstivorminduse valikud"</string>
<string name="rich_text_editor_code_block">"Lülita lähtekoodi lõik sisse/välja"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Selgitus või nimi, kui soovid lisada…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Krüptitud sõnum…"</string>
<string name="rich_text_editor_composer_placeholder">"Sõnum…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Krüptimata sõnum…"</string>
<string name="rich_text_editor_create_link">"Lisa link"</string>
<string name="rich_text_editor_edit_link">"Muuda linki"</string>
<string name="rich_text_editor_format_action">"%1$s, olek: %2$s"</string>
<string name="rich_text_editor_format_bold">"Kasuta paksu kirja"</string>
<string name="rich_text_editor_format_italic">"Kasuta kaldkirja"</string>
<string name="rich_text_editor_format_state_disabled">"pole kasutusel"</string>
<string name="rich_text_editor_format_state_off">"väljas"</string>
<string name="rich_text_editor_format_state_on">"sees"</string>
<string name="rich_text_editor_format_strikethrough">"Kasuta läbikriipsutatud kirja"</string>
<string name="rich_text_editor_format_underline">"Kasuta allajoonitud kirja"</string>
<string name="rich_text_editor_full_screen_toggle">"Lülita täisekraanivaade sisse/välja"</string>
<string name="rich_text_editor_indent">"Lisa taandrida"</string>
<string name="rich_text_editor_inline_code">"Kuva lähtekoodi lõiguna"</string>
<string name="rich_text_editor_link">"Lisa link"</string>
<string name="rich_text_editor_numbered_list">"Lülita nummerdatud loend sisse/välja"</string>
<string name="rich_text_editor_open_compose_options">"Ava vorminduse valikud"</string>
<string name="rich_text_editor_quote">"Lülita tsiteerimine sisse/välja"</string>
<string name="rich_text_editor_remove_link">"Eemalda link"</string>
<string name="rich_text_editor_unindent">"Eemalda taandrida"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_media_upload_preview_caption_warning">"Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele."</string>
<string name="screen_room_voice_message_tooltip">"Salvestamiseks hoia nuppu all"</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Gehitu eranskina"</string>
<string name="rich_text_editor_bullet_list">"Buleten zerrenda bai/ez"</string>
<string name="rich_text_editor_close_formatting_options">"Baztertu eta itxi formatu aukerak"</string>
<string name="rich_text_editor_code_block">"Kode-blokea bai/ez"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Gehitu testua"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Zifratutako mezua…"</string>
<string name="rich_text_editor_composer_placeholder">"Mezua…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Zifratu gabeko mezua…"</string>
<string name="rich_text_editor_create_link">"Sortu esteka"</string>
<string name="rich_text_editor_edit_link">"Editatu esteka"</string>
<string name="rich_text_editor_format_bold">"Aplikatu formatu lodia"</string>
<string name="rich_text_editor_format_italic">"Aplikatu formatu etzana"</string>
<string name="rich_text_editor_format_state_disabled">"desgaituta"</string>
<string name="rich_text_editor_format_state_off">"itzalita"</string>
<string name="rich_text_editor_format_state_on">"piztuta"</string>
<string name="rich_text_editor_format_strikethrough">"Aplikatu ezabaketa formatua"</string>
<string name="rich_text_editor_format_underline">"Aplikatu azpimarra formatua"</string>
<string name="rich_text_editor_full_screen_toggle">"Pantaila osoa bai/ez"</string>
<string name="rich_text_editor_indent">"Koska"</string>
<string name="rich_text_editor_link">"Ezarri esteka"</string>
<string name="rich_text_editor_numbered_list">"Zenbakidu zerrenda bai/ez"</string>
<string name="rich_text_editor_open_compose_options">"Ireki idazketa aukerak"</string>
<string name="rich_text_editor_quote">"Aipua bai/ez"</string>
<string name="rich_text_editor_remove_link">"Kendu esteka"</string>
<string name="rich_text_editor_unindent">"Koskarik gabe"</string>
<string name="rich_text_editor_url_placeholder">"Esteka"</string>
<string name="screen_room_voice_message_tooltip">"Mantendu sakatuta grabatzeko"</string>
</resources>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"افزودن پیوست"</string>
<string name="rich_text_editor_bullet_list">"تغییر وضعیت سیاههٔ گلوله‌ای"</string>
<string name="rich_text_editor_close_formatting_options">"لغو و بستن قالب‌بندی متن"</string>
<string name="rich_text_editor_code_block">"تغییر حالت بلوک کد"</string>
<string name="rich_text_editor_composer_caption_placeholder">"افزودن عنوان"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"پیام رمزنگاری شده…"</string>
<string name="rich_text_editor_composer_placeholder">"پیام…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"پیام رمزنگاری نشده…"</string>
<string name="rich_text_editor_create_link">"ایجاد پیوند"</string>
<string name="rich_text_editor_edit_link">"ویرایش پیوند"</string>
<string name="rich_text_editor_format_action">"%1$s. وضعیت: %2$s"</string>
<string name="rich_text_editor_format_bold">"اعمال قالب توپر"</string>
<string name="rich_text_editor_format_italic">"اعمال قالب کج"</string>
<string name="rich_text_editor_format_state_disabled">"از کار افتاده"</string>
<string name="rich_text_editor_format_state_off">"خاموش"</string>
<string name="rich_text_editor_format_state_on">"روشن"</string>
<string name="rich_text_editor_format_strikethrough">"اعمال قالب خط‌خورده"</string>
<string name="rich_text_editor_format_underline">"اعمال قالب زیرخط‌دار"</string>
<string name="rich_text_editor_full_screen_toggle">"تغییر حالت تمام‌صفحه"</string>
<string name="rich_text_editor_indent">"تورفتگی"</string>
<string name="rich_text_editor_inline_code">"اعمال قالب کد درون‌خط"</string>
<string name="rich_text_editor_link">"تنظیم پیوند"</string>
<string name="rich_text_editor_numbered_list">"تغییر وضعیت سیاههٔ شماره‌دار"</string>
<string name="rich_text_editor_open_compose_options">"گشودن گزینه‌های نوشتن"</string>
<string name="rich_text_editor_quote">"تغییر حالت نقل قول"</string>
<string name="rich_text_editor_remove_link">"برداشتن پیوند"</string>
<string name="rich_text_editor_unindent">"تونرفتگی"</string>
<string name="rich_text_editor_url_placeholder">"پیوند"</string>
<string name="screen_room_voice_message_tooltip">"نگه داشتن برای ضبط"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Lisää liite"</string>
<string name="rich_text_editor_bullet_list">"Numeroimaton luettelo päälle/pois"</string>
<string name="rich_text_editor_close_formatting_options">"Peruuta ja sulje muotoiluasetukset"</string>
<string name="rich_text_editor_code_block">"Koodilohko päälle/pois"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Lisää kuvateksti"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Salattu viesti…"</string>
<string name="rich_text_editor_composer_placeholder">"Viesti…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Salaamaton viesti…"</string>
<string name="rich_text_editor_create_link">"Luo linkki"</string>
<string name="rich_text_editor_edit_link">"Muokkaa linkkiä"</string>
<string name="rich_text_editor_format_action">"%1$s, tila: %2$s"</string>
<string name="rich_text_editor_format_bold">"Käytä lihavoitua muotoa"</string>
<string name="rich_text_editor_format_italic">"Käytä kursiivimuotoa"</string>
<string name="rich_text_editor_format_state_disabled">"poissa käytöstä"</string>
<string name="rich_text_editor_format_state_off">"pois päältä"</string>
<string name="rich_text_editor_format_state_on">"päällä"</string>
<string name="rich_text_editor_format_strikethrough">"Käytä yliviivausmuotoa"</string>
<string name="rich_text_editor_format_underline">"Käytä alleviivausmuotoa"</string>
<string name="rich_text_editor_full_screen_toggle">"Koko näytön tila päälle/pois"</string>
<string name="rich_text_editor_indent">"Sisennä"</string>
<string name="rich_text_editor_inline_code">"Käytä rivinsisäistä koodimuotoa"</string>
<string name="rich_text_editor_link">"Aseta linkki"</string>
<string name="rich_text_editor_numbered_list">"Numeroitu luettelo päälle/pois"</string>
<string name="rich_text_editor_open_compose_options">"Avaa kirjoitusvaihtoehdot"</string>
<string name="rich_text_editor_quote">"Lainaus päälle/pois"</string>
<string name="rich_text_editor_remove_link">"Poista linkki"</string>
<string name="rich_text_editor_unindent">"Poista sisennys"</string>
<string name="rich_text_editor_url_placeholder">"Linkki"</string>
<string name="screen_media_upload_preview_caption_warning">"Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia."</string>
<string name="screen_room_voice_message_tooltip">"Pidä pohjassa nauhoittaaksesi"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Ajouter une pièce jointe"</string>
<string name="rich_text_editor_bullet_list">"Afficher une liste à puces"</string>
<string name="rich_text_editor_close_formatting_options">"Annuler et fermer les options de formatage"</string>
<string name="rich_text_editor_code_block">"Afficher le bloc de code"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Légende facultative…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Message chiffré…"</string>
<string name="rich_text_editor_composer_placeholder">"Message…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Message non chiffré…"</string>
<string name="rich_text_editor_create_link">"Créer un lien"</string>
<string name="rich_text_editor_edit_link">"Modifier le lien"</string>
<string name="rich_text_editor_format_action">"%1$s, état : %2$s"</string>
<string name="rich_text_editor_format_bold">"Appliquer le format gras"</string>
<string name="rich_text_editor_format_italic">"Appliquer le format italique"</string>
<string name="rich_text_editor_format_state_disabled">"désactivé"</string>
<string name="rich_text_editor_format_state_off">"désactivé"</string>
<string name="rich_text_editor_format_state_on">"activé"</string>
<string name="rich_text_editor_format_strikethrough">"Appliquer le format barré"</string>
<string name="rich_text_editor_format_underline">"Appliquer le format souligné"</string>
<string name="rich_text_editor_full_screen_toggle">"Activer/désactiver le mode plein écran"</string>
<string name="rich_text_editor_indent">"Décaler vers la droite"</string>
<string name="rich_text_editor_inline_code">"Appliquer le formatage de code en ligne"</string>
<string name="rich_text_editor_link">"Définir un lien"</string>
<string name="rich_text_editor_numbered_list">"Afficher une liste numérotée"</string>
<string name="rich_text_editor_open_compose_options">"Ouvrir les options de rédaction"</string>
<string name="rich_text_editor_quote">"Afficher/masquer la citation"</string>
<string name="rich_text_editor_remove_link">"Supprimer le lien"</string>
<string name="rich_text_editor_unindent">"Décaler vers la gauche"</string>
<string name="rich_text_editor_url_placeholder">"Lien"</string>
<string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs danciennes applications."</string>
<string name="screen_room_voice_message_tooltip">"Maintenir pour enregistrer"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Melléklet hozzáadása"</string>
<string name="rich_text_editor_bullet_list">"Felsorolás be/ki"</string>
<string name="rich_text_editor_close_formatting_options">"Mégse, és a formázási beállítások bezárása"</string>
<string name="rich_text_editor_code_block">"Kódblokk be/ki"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Felirat hozzáadása…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Titkosított üzenet…"</string>
<string name="rich_text_editor_composer_placeholder">"Üzenet…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Titkosítatlan üzenet…"</string>
<string name="rich_text_editor_create_link">"Hivatkozás létrehozása"</string>
<string name="rich_text_editor_edit_link">"Hivatkozás szerkesztése"</string>
<string name="rich_text_editor_format_action">"%1$s, állapot: %2$s"</string>
<string name="rich_text_editor_format_bold">"Félkövér formátum alkalmazása"</string>
<string name="rich_text_editor_format_italic">"Dőlt formátum alkalmazása"</string>
<string name="rich_text_editor_format_state_disabled">"letiltva"</string>
<string name="rich_text_editor_format_state_off">"ki"</string>
<string name="rich_text_editor_format_state_on">"be"</string>
<string name="rich_text_editor_format_strikethrough">"Áthúzott formátum alkalmazása"</string>
<string name="rich_text_editor_format_underline">"Aláhúzott formátum alkalmazása"</string>
<string name="rich_text_editor_full_screen_toggle">"Teljes képernyős mód be/ki"</string>
<string name="rich_text_editor_indent">"Behúzás"</string>
<string name="rich_text_editor_inline_code">"Soron belüli kód formátum alkalmazása"</string>
<string name="rich_text_editor_link">"Hivatkozás beállítása"</string>
<string name="rich_text_editor_numbered_list">"Számozott lista be/ki"</string>
<string name="rich_text_editor_open_compose_options">"Írási beállítások megnyitása"</string>
<string name="rich_text_editor_quote">"Idézet be/ki"</string>
<string name="rich_text_editor_remove_link">"Hivatkozás eltávolítása"</string>
<string name="rich_text_editor_unindent">"Behúzás nélkül"</string>
<string name="rich_text_editor_url_placeholder">"Hivatkozás"</string>
<string name="screen_media_upload_preview_caption_warning">"Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára."</string>
<string name="screen_room_voice_message_tooltip">"Tartsa a rögzítéshez"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Tambahkan lampiran"</string>
<string name="rich_text_editor_bullet_list">"Alihkan daftar poin"</string>
<string name="rich_text_editor_close_formatting_options">"Batalkan dan tutup pemformatan teks"</string>
<string name="rich_text_editor_code_block">"Alihkan blok kode"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Keterangan opsional…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Pesan terenkripsi…"</string>
<string name="rich_text_editor_composer_placeholder">"Kirim pesan…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Pesan tidak terenkripsi…"</string>
<string name="rich_text_editor_create_link">"Buat tautan"</string>
<string name="rich_text_editor_edit_link">"Sunting tautan"</string>
<string name="rich_text_editor_format_action">"%1$s, keadaan: %2$s"</string>
<string name="rich_text_editor_format_bold">"Terapkan format tebal"</string>
<string name="rich_text_editor_format_italic">"Terapkan format miring"</string>
<string name="rich_text_editor_format_state_disabled">"dinonaktifkan"</string>
<string name="rich_text_editor_format_state_off">"mati"</string>
<string name="rich_text_editor_format_state_on">"nyala"</string>
<string name="rich_text_editor_format_strikethrough">"Terapkan format coret"</string>
<string name="rich_text_editor_format_underline">"Terapkan format garis bawah"</string>
<string name="rich_text_editor_full_screen_toggle">"Alihkan mode layar penuh"</string>
<string name="rich_text_editor_indent">"Beri indentasi"</string>
<string name="rich_text_editor_inline_code">"Terapkan format kode dalam baris"</string>
<string name="rich_text_editor_link">"Tetapkan tautan"</string>
<string name="rich_text_editor_numbered_list">"Alihkan daftar bernomor"</string>
<string name="rich_text_editor_open_compose_options">"Buka opsi penulisan"</string>
<string name="rich_text_editor_quote">"Alihkan kutipan"</string>
<string name="rich_text_editor_remove_link">"Hapus tautan"</string>
<string name="rich_text_editor_unindent">"Hapus indentasi"</string>
<string name="rich_text_editor_url_placeholder">"Tautan"</string>
<string name="screen_media_upload_preview_caption_warning">"Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama."</string>
<string name="screen_room_voice_message_tooltip">"Tahan untuk merekam"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Aggiungi allegato"</string>
<string name="rich_text_editor_bullet_list">"Attiva/disattiva l\'elenco puntato"</string>
<string name="rich_text_editor_close_formatting_options">"Annullare e chiudere la formattazione del testo"</string>
<string name="rich_text_editor_code_block">"Attiva/disattiva il blocco di codice"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Aggiungi una didascalia"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Messaggio cifrato…"</string>
<string name="rich_text_editor_composer_placeholder">"Messaggio…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Messaggio non cifrato…"</string>
<string name="rich_text_editor_create_link">"Crea un collegamento"</string>
<string name="rich_text_editor_edit_link">"Modifica collegamento"</string>
<string name="rich_text_editor_format_action">"%1$s stato: %2$s"</string>
<string name="rich_text_editor_format_bold">"Applica il formato grassetto"</string>
<string name="rich_text_editor_format_italic">"Applicare il formato corsivo"</string>
<string name="rich_text_editor_format_state_disabled">"disabilitato"</string>
<string name="rich_text_editor_format_state_off">"Disattivato"</string>
<string name="rich_text_editor_format_state_on">"Attivo"</string>
<string name="rich_text_editor_format_strikethrough">"Applica il formato barrato"</string>
<string name="rich_text_editor_format_underline">"Applicare il formato di sottolineatura"</string>
<string name="rich_text_editor_full_screen_toggle">"Attiva/disattiva la modalità a schermo intero"</string>
<string name="rich_text_editor_indent">"Rientro a destra"</string>
<string name="rich_text_editor_inline_code">"Applicare il formato codice inline"</string>
<string name="rich_text_editor_link">"Imposta collegamento"</string>
<string name="rich_text_editor_numbered_list">"Attiva/disattiva elenco numerato"</string>
<string name="rich_text_editor_open_compose_options">"Apri le opzioni di composizione"</string>
<string name="rich_text_editor_quote">"Attiva/disattiva citazione"</string>
<string name="rich_text_editor_remove_link">"Rimuovi collegamento"</string>
<string name="rich_text_editor_unindent">"Rientro a sinistra"</string>
<string name="rich_text_editor_url_placeholder">"Collegamento"</string>
<string name="screen_media_upload_preview_caption_warning">"Le didascalie potrebbero non essere visibili agli utenti di app meno recenti."</string>
<string name="screen_room_voice_message_tooltip">"Tieni premuto per registrare"</string>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"დაამატეთ დანართი"</string>
<string name="rich_text_editor_bullet_list">"პუნქტების სიის ჩართვა"</string>
<string name="rich_text_editor_close_formatting_options">"ფორმატირების პარამეტრები დახურვა"</string>
<string name="rich_text_editor_code_block">"კოდის ბლოკის ჩართვა"</string>
<string name="rich_text_editor_composer_placeholder">"შეტყობინება…"</string>
<string name="rich_text_editor_create_link">"ბმულის შექმნა"</string>
<string name="rich_text_editor_edit_link">"ბმულის რედაქტირება"</string>
<string name="rich_text_editor_format_bold">"თამამი შრიფტის გამოყენება"</string>
<string name="rich_text_editor_format_italic">"კურსიული შრიფტის გამოყენება"</string>
<string name="rich_text_editor_format_strikethrough">"გადახაზული ფორმატის გამოყენება"</string>
<string name="rich_text_editor_format_underline">"ხაზგასმული ფორმატის გამოყენება"</string>
<string name="rich_text_editor_full_screen_toggle">"სრული ეკრანის რეჟიმის ჩართვა"</string>
<string name="rich_text_editor_indent">"აბზაცი"</string>
<string name="rich_text_editor_inline_code">"კოდის შიდა ფორმატის გამოყენება"</string>
<string name="rich_text_editor_link">"ბმულის დაყენება"</string>
<string name="rich_text_editor_numbered_list">"დანომრილი სიის ჩართვა"</string>
<string name="rich_text_editor_open_compose_options">"გახსენით შედგენის ვარიანტები"</string>
<string name="rich_text_editor_quote">"ციტატის ჩართვა"</string>
<string name="rich_text_editor_remove_link">"ბმულის წაშლა"</string>
<string name="rich_text_editor_unindent">"აბზაცის გარეშე"</string>
<string name="rich_text_editor_url_placeholder">"Ბმული"</string>
<string name="screen_room_voice_message_tooltip">"ჩასაწერად დააჭირეთ"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"첨부파일 추가"</string>
<string name="rich_text_editor_bullet_list">"글머리 기호 목록 전환"</string>
<string name="rich_text_editor_close_formatting_options">"텍스트 서식 취소 및 닫기"</string>
<string name="rich_text_editor_code_block">"코드 블록 전환"</string>
<string name="rich_text_editor_composer_caption_placeholder">"캡션을 추가하세요"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"암호화된 메세지…"</string>
<string name="rich_text_editor_composer_placeholder">"메시지…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"비암호화된 메시지…"</string>
<string name="rich_text_editor_create_link">"링크 생성"</string>
<string name="rich_text_editor_edit_link">"링크 수정"</string>
<string name="rich_text_editor_format_action">"%1$s, 상태: %2$s"</string>
<string name="rich_text_editor_format_bold">"굵음 적용"</string>
<string name="rich_text_editor_format_italic">"기울임 적용"</string>
<string name="rich_text_editor_format_state_disabled">"비활성화됨"</string>
<string name="rich_text_editor_format_state_off">"끄기"</string>
<string name="rich_text_editor_format_state_on">"켜기"</string>
<string name="rich_text_editor_format_strikethrough">"취소선 적용"</string>
<string name="rich_text_editor_format_underline">"밑줄 적용"</string>
<string name="rich_text_editor_full_screen_toggle">"전체화면 모드 전환"</string>
<string name="rich_text_editor_indent">"들여쓰기"</string>
<string name="rich_text_editor_inline_code">"인라인 코드 형식 적용"</string>
<string name="rich_text_editor_link">"링크 설정"</string>
<string name="rich_text_editor_numbered_list">"숫자 목록 전환"</string>
<string name="rich_text_editor_open_compose_options">"작성 옵션 열기"</string>
<string name="rich_text_editor_quote">"인용 전환"</string>
<string name="rich_text_editor_remove_link">"링크 제거"</string>
<string name="rich_text_editor_unindent">"들여쓰기 취소"</string>
<string name="rich_text_editor_url_placeholder">"링크"</string>
<string name="screen_media_upload_preview_caption_warning">"캡션은 오래된 앱을 사용하는 사용자에게 표시되지 않을 수 있습니다."</string>
<string name="screen_room_voice_message_tooltip">"녹음하려면 길게 누르세요."</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_bullet_list">"Perjungti punktų sąrašą"</string>
<string name="rich_text_editor_code_block">"Kodo blokas"</string>
<string name="rich_text_editor_composer_placeholder">"Žinutė…"</string>
<string name="rich_text_editor_format_bold">"Taikyti paryškintą formatą"</string>
<string name="rich_text_editor_format_italic">"Taikyti pasvirusį formatą"</string>
<string name="rich_text_editor_format_strikethrough">"Taikyti perbrauktą formatą"</string>
<string name="rich_text_editor_format_underline">"Taikyti pabrauktą formatą"</string>
<string name="rich_text_editor_full_screen_toggle">"Perjungti viso ekrano režimą"</string>
<string name="rich_text_editor_indent">"Atitraukti"</string>
<string name="rich_text_editor_inline_code">"Taikyti įterpto kodo formatą"</string>
<string name="rich_text_editor_link">"Nustatyti nuorodą"</string>
<string name="rich_text_editor_numbered_list">"Perjungti sunumeruotą sąrašą"</string>
<string name="rich_text_editor_quote">"Cituoti"</string>
<string name="rich_text_editor_unindent">"Panaikinti atitraukimą"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Legg til vedlegg"</string>
<string name="rich_text_editor_bullet_list">"Aktiver/deaktiver punktliste"</string>
<string name="rich_text_editor_close_formatting_options">"Avbryt og lukk formateringsvalg"</string>
<string name="rich_text_editor_code_block">"Aktiver kodeblokk"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Legg til en tekstbeskrivelse"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Kryptert melding…"</string>
<string name="rich_text_editor_composer_placeholder">"Melding…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Ukryptert melding…"</string>
<string name="rich_text_editor_create_link">"Opprett en lenke"</string>
<string name="rich_text_editor_edit_link">"Rediger lenke"</string>
<string name="rich_text_editor_format_action">"%1$s, tilstand: %2$s"</string>
<string name="rich_text_editor_format_bold">"Bruk fet skrift"</string>
<string name="rich_text_editor_format_italic">"Bruk kursivformat"</string>
<string name="rich_text_editor_format_state_disabled">"deaktivert"</string>
<string name="rich_text_editor_format_state_off">"av"</string>
<string name="rich_text_editor_format_state_on">"på"</string>
<string name="rich_text_editor_format_strikethrough">"Bruke gjennomstrekingsformat"</string>
<string name="rich_text_editor_format_underline">"Bruke understrekingsformat"</string>
<string name="rich_text_editor_full_screen_toggle">"Veksle fullskjermmodus"</string>
<string name="rich_text_editor_indent">"Innrykk"</string>
<string name="rich_text_editor_inline_code">"Bruk inline-kodeformat"</string>
<string name="rich_text_editor_link">"Angi lenke"</string>
<string name="rich_text_editor_numbered_list">"Aktiver/deaktiver nummerert liste"</string>
<string name="rich_text_editor_open_compose_options">"Åpne skrivealternativer"</string>
<string name="rich_text_editor_quote">"Slå på sitat"</string>
<string name="rich_text_editor_remove_link">"Fjern lenke"</string>
<string name="rich_text_editor_unindent">"Fjern innrykk"</string>
<string name="rich_text_editor_url_placeholder">"Lenke"</string>
<string name="screen_media_upload_preview_caption_warning">"Teksting er kanskje ikke synlig for personer som bruker eldre apper."</string>
<string name="screen_room_voice_message_tooltip">"Hold for å ta opp"</string>
</resources>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Bijlage toevoegen"</string>
<string name="rich_text_editor_bullet_list">"Lijst met opsommingstekens in-/uitschakelen"</string>
<string name="rich_text_editor_close_formatting_options">"Annuleren en opmaakopties sluiten"</string>
<string name="rich_text_editor_code_block">"Codeblok in-/uitschakelen"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Bijschrift toevoegen"</string>
<string name="rich_text_editor_composer_placeholder">"Bericht…"</string>
<string name="rich_text_editor_create_link">"Maak een link"</string>
<string name="rich_text_editor_edit_link">"Link bewerken"</string>
<string name="rich_text_editor_format_bold">"Vetgedrukte opmaak toepassen"</string>
<string name="rich_text_editor_format_italic">"Cursieve opmaak toepassen"</string>
<string name="rich_text_editor_format_strikethrough">"Doorgestreepte opmaak toepassen"</string>
<string name="rich_text_editor_format_underline">"Onderstreepte opmaak toepassen"</string>
<string name="rich_text_editor_full_screen_toggle">"Modus volledig scherm in-/uitschakelen"</string>
<string name="rich_text_editor_indent">"Inspringen"</string>
<string name="rich_text_editor_inline_code">"Inline code-opmaak toepassen"</string>
<string name="rich_text_editor_link">"Link instellen"</string>
<string name="rich_text_editor_numbered_list">"Genummerde lijst in-/uitschakelen"</string>
<string name="rich_text_editor_open_compose_options">"Open opstelopties"</string>
<string name="rich_text_editor_quote">"Citaat in-/uitschakelen"</string>
<string name="rich_text_editor_remove_link">"Link verwijderen"</string>
<string name="rich_text_editor_unindent">"Inspringing ongedaan maken"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_room_voice_message_tooltip">"Vasthouden om op te nemen"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Dodaj załącznik"</string>
<string name="rich_text_editor_bullet_list">"Przełącz listę punktową"</string>
<string name="rich_text_editor_close_formatting_options">"Anuluj i zamknij formatowanie tekstu"</string>
<string name="rich_text_editor_code_block">"Przełącz blok kodu"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Dodaj opis"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Wiadomość szyfrowana…"</string>
<string name="rich_text_editor_composer_placeholder">"Wiadomość…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Niezaszyfrowana wiadomość…"</string>
<string name="rich_text_editor_create_link">"Utwórz link"</string>
<string name="rich_text_editor_edit_link">"Edytuj link"</string>
<string name="rich_text_editor_format_action">"%1$s, stan: %2$s"</string>
<string name="rich_text_editor_format_bold">"Zastosuj pogrubienie"</string>
<string name="rich_text_editor_format_italic">"Zastosuj kursywę"</string>
<string name="rich_text_editor_format_state_disabled">"wyłączony"</string>
<string name="rich_text_editor_format_state_off">"wyłączony"</string>
<string name="rich_text_editor_format_state_on">"włączony"</string>
<string name="rich_text_editor_format_strikethrough">"Zastosuj przekreślenie"</string>
<string name="rich_text_editor_format_underline">"Zastosuj podkreślenie"</string>
<string name="rich_text_editor_full_screen_toggle">"Przełącz pełny ekran"</string>
<string name="rich_text_editor_indent">"Wcięcie"</string>
<string name="rich_text_editor_inline_code">"Zastosuj formatowanie kodu w wierszu"</string>
<string name="rich_text_editor_link">"Wstaw link"</string>
<string name="rich_text_editor_numbered_list">"Przełącz listę numerowaną"</string>
<string name="rich_text_editor_open_compose_options">"Otwórz opcje tworzenia"</string>
<string name="rich_text_editor_quote">"Przełącz cytat"</string>
<string name="rich_text_editor_remove_link">"Usuń link"</string>
<string name="rich_text_editor_unindent">"Bez wcięcia"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_media_upload_preview_caption_warning">"Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji."</string>
<string name="screen_room_voice_message_tooltip">"Przytrzymaj, aby nagrywać"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Adicionar anexo"</string>
<string name="rich_text_editor_bullet_list">"Habilitar lista de objetivos"</string>
<string name="rich_text_editor_close_formatting_options">"Cancelar e fechar opções de formatação"</string>
<string name="rich_text_editor_code_block">"Habilitar bloco de código"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Adicionar uma legenda"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Mensagem criptografada…"</string>
<string name="rich_text_editor_composer_placeholder">"Mensagem…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Mensagem não criptografada…"</string>
<string name="rich_text_editor_create_link">"Criar um link"</string>
<string name="rich_text_editor_edit_link">"Editar link"</string>
<string name="rich_text_editor_format_action">"%1$s, estado: %2$s"</string>
<string name="rich_text_editor_format_bold">"Aplicar formato em negrito"</string>
<string name="rich_text_editor_format_italic">"Aplicar itálico"</string>
<string name="rich_text_editor_format_state_disabled">"desativado"</string>
<string name="rich_text_editor_format_state_off">"desligado"</string>
<string name="rich_text_editor_format_state_on">"ligado"</string>
<string name="rich_text_editor_format_strikethrough">"Aplicar risco"</string>
<string name="rich_text_editor_format_underline">"Aplicar sublinhado"</string>
<string name="rich_text_editor_full_screen_toggle">"Habilitar o modo de tela cheia"</string>
<string name="rich_text_editor_indent">"Identar"</string>
<string name="rich_text_editor_inline_code">"Aplicar código na mesma linha"</string>
<string name="rich_text_editor_link">"Definir link"</string>
<string name="rich_text_editor_numbered_list">"Habilitar lista numerada"</string>
<string name="rich_text_editor_open_compose_options">"Abrir opções de composição"</string>
<string name="rich_text_editor_quote">"Habilitar citação"</string>
<string name="rich_text_editor_remove_link">"Remover link"</string>
<string name="rich_text_editor_unindent">"Desidentar"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_media_upload_preview_caption_warning">"As legendas podem não ser visíveis para pessoas que usam apps mais antigos."</string>
<string name="screen_room_voice_message_tooltip">"Segure para gravar"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Adicionar anexo"</string>
<string name="rich_text_editor_bullet_list">"Ativar/desativar lista de pontos"</string>
<string name="rich_text_editor_close_formatting_options">"Cancelar e fechar opções de formatação"</string>
<string name="rich_text_editor_code_block">"Ativar/desativar bloco de código"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Legenda opcional…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Mensagem encriptada…"</string>
<string name="rich_text_editor_composer_placeholder">"Mensagem…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Mensagem não encriptada…"</string>
<string name="rich_text_editor_create_link">"Criar uma ligação"</string>
<string name="rich_text_editor_edit_link">"Editar ligação"</string>
<string name="rich_text_editor_format_action">"%1$s, estado: %2$s"</string>
<string name="rich_text_editor_format_bold">"Aplicar negrito"</string>
<string name="rich_text_editor_format_italic">"Aplicar itálico"</string>
<string name="rich_text_editor_format_state_disabled">"desativado"</string>
<string name="rich_text_editor_format_state_off">"desligado"</string>
<string name="rich_text_editor_format_state_on">"ligado"</string>
<string name="rich_text_editor_format_strikethrough">"Aplicar rasura"</string>
<string name="rich_text_editor_format_underline">"Aplicar sublinhado"</string>
<string name="rich_text_editor_full_screen_toggle">"Entrar/sair do modo de ecrã inteiro"</string>
<string name="rich_text_editor_indent">"Indentar"</string>
<string name="rich_text_editor_inline_code">"Aplicar código em linha"</string>
<string name="rich_text_editor_link">"Definir ligação"</string>
<string name="rich_text_editor_numbered_list">"Alternar lista numerada"</string>
<string name="rich_text_editor_open_compose_options">"Abrir opções de escrita"</string>
<string name="rich_text_editor_quote">"Pôr/tirar aspas"</string>
<string name="rich_text_editor_remove_link">"Remover ligação"</string>
<string name="rich_text_editor_unindent">"Desindentar"</string>
<string name="rich_text_editor_url_placeholder">"Ligação"</string>
<string name="screen_media_upload_preview_caption_warning">"As legendas poderão não ser visíveis em versões mais antigas da aplicação."</string>
<string name="screen_room_voice_message_tooltip">"Segurar para gravar"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Adăugați un atașament"</string>
<string name="rich_text_editor_bullet_list">"Comutați lista cu puncte"</string>
<string name="rich_text_editor_close_formatting_options">"Închideți opțiunile de formatare"</string>
<string name="rich_text_editor_code_block">"Comutați blocul de cod"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Adăugați o descriere"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Mesaj criptat…"</string>
<string name="rich_text_editor_composer_placeholder">"Mesaj…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Mesaj necriptat…"</string>
<string name="rich_text_editor_create_link">"Creați un link"</string>
<string name="rich_text_editor_edit_link">"Editați link-ul"</string>
<string name="rich_text_editor_format_action">"%1$s, stare: %2$s"</string>
<string name="rich_text_editor_format_bold">"Aplicați formatul aldin"</string>
<string name="rich_text_editor_format_italic">"Aplicați formatul italic"</string>
<string name="rich_text_editor_format_state_disabled">"dezactivat"</string>
<string name="rich_text_editor_format_state_off">"dezactivat"</string>
<string name="rich_text_editor_format_state_on">"activat"</string>
<string name="rich_text_editor_format_strikethrough">"Aplicați formatul barat"</string>
<string name="rich_text_editor_format_underline">"Aplică formatul de subliniere"</string>
<string name="rich_text_editor_full_screen_toggle">"Comutați modul ecran complet"</string>
<string name="rich_text_editor_indent">"Indentare"</string>
<string name="rich_text_editor_inline_code">"Aplicați formatul de cod inline"</string>
<string name="rich_text_editor_link">"Setați linkul"</string>
<string name="rich_text_editor_numbered_list">"Comutați lista numerotată"</string>
<string name="rich_text_editor_open_compose_options">"Deschideți opțiunile de compunere"</string>
<string name="rich_text_editor_quote">"Aplicați citatul"</string>
<string name="rich_text_editor_remove_link">"Ștergeți linkul"</string>
<string name="rich_text_editor_unindent">"Dez-identare"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_media_upload_preview_caption_warning">"Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi."</string>
<string name="screen_room_voice_message_tooltip">"Țineți apăsat pentru a înregistra"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Прикрепить файл"</string>
<string name="rich_text_editor_bullet_list">"Переключить список маркеров"</string>
<string name="rich_text_editor_close_formatting_options">"Отменить и закрыть параметры форматирования"</string>
<string name="rich_text_editor_code_block">"Переключить блок кода"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Необязательный заголовок…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Зашифрованное сообщение…"</string>
<string name="rich_text_editor_composer_placeholder">"Сообщение…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Незашифрованное сообщение…"</string>
<string name="rich_text_editor_create_link">"Создать ссылку"</string>
<string name="rich_text_editor_edit_link">"Редактировать ссылку"</string>
<string name="rich_text_editor_format_action">"%1$s, состояние: %2$s"</string>
<string name="rich_text_editor_format_bold">"Применить жирный шрифт"</string>
<string name="rich_text_editor_format_italic">"Применить курсивный формат"</string>
<string name="rich_text_editor_format_state_disabled">"отключено"</string>
<string name="rich_text_editor_format_state_off">"ОТКЛ."</string>
<string name="rich_text_editor_format_state_on">"ВКЛ"</string>
<string name="rich_text_editor_format_strikethrough">"Применить формат зачеркивания"</string>
<string name="rich_text_editor_format_underline">"Применить формат подчеркивания"</string>
<string name="rich_text_editor_full_screen_toggle">"Переключение полноэкранного режима"</string>
<string name="rich_text_editor_indent">"Отступ"</string>
<string name="rich_text_editor_inline_code">"Применить встроенный формат кода"</string>
<string name="rich_text_editor_link">"Установить ссылку"</string>
<string name="rich_text_editor_numbered_list">"Переключить нумерованный список"</string>
<string name="rich_text_editor_open_compose_options">"Открыть параметры компоновки"</string>
<string name="rich_text_editor_quote">"Переключить цитату"</string>
<string name="rich_text_editor_remove_link">"Удалить ссылку"</string>
<string name="rich_text_editor_unindent">"Без отступа"</string>
<string name="rich_text_editor_url_placeholder">"Ссылка"</string>
<string name="screen_media_upload_preview_caption_warning">"Подпись может быть не видна пользователям старых приложений."</string>
<string name="screen_room_voice_message_tooltip">"Удерживайте для записи"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Pridať prílohu"</string>
<string name="rich_text_editor_bullet_list">"Prepnúť zoznam odrážok"</string>
<string name="rich_text_editor_close_formatting_options">"Zrušiť a zatvoriť formátovanie textu"</string>
<string name="rich_text_editor_code_block">"Prepnúť blok kódu"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Voliteľný titulok…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Šifrovaná správa…"</string>
<string name="rich_text_editor_composer_placeholder">"Správa…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Nešifrovaná správa…"</string>
<string name="rich_text_editor_create_link">"Vytvoriť odkaz"</string>
<string name="rich_text_editor_edit_link">"Upraviť odkaz"</string>
<string name="rich_text_editor_format_action">"%1$s, stav: %2$s"</string>
<string name="rich_text_editor_format_bold">"Použiť tučný formát"</string>
<string name="rich_text_editor_format_italic">"Použiť formát kurzívy"</string>
<string name="rich_text_editor_format_state_disabled">"zakázané"</string>
<string name="rich_text_editor_format_state_off">"vypnuté"</string>
<string name="rich_text_editor_format_state_on">"zapnuté"</string>
<string name="rich_text_editor_format_strikethrough">"Použiť formát prečiarknutia"</string>
<string name="rich_text_editor_format_underline">"Použiť formát podčiarknutia"</string>
<string name="rich_text_editor_full_screen_toggle">"Prepnúť režim celej obrazovky"</string>
<string name="rich_text_editor_indent">"Odsadenie"</string>
<string name="rich_text_editor_inline_code">"Použiť formát riadkového kódu"</string>
<string name="rich_text_editor_link">"Nastaviť odkaz"</string>
<string name="rich_text_editor_numbered_list">"Prepnúť číslovaný zoznam"</string>
<string name="rich_text_editor_open_compose_options">"Otvoriť možnosti písania"</string>
<string name="rich_text_editor_quote">"Prepnúť citáciu"</string>
<string name="rich_text_editor_remove_link">"Odstrániť odkaz"</string>
<string name="rich_text_editor_unindent">"Zrušiť odsadenie"</string>
<string name="rich_text_editor_url_placeholder">"Odkaz"</string>
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie."</string>
<string name="screen_room_voice_message_tooltip">"Podržaním nahrajte"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Lägg till bilaga"</string>
<string name="rich_text_editor_bullet_list">"Växla punktlista"</string>
<string name="rich_text_editor_close_formatting_options">"Avbryt och stäng textformatering"</string>
<string name="rich_text_editor_code_block">"Växla kodblock"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Lägg till en bildtext"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Krypterat meddelande …"</string>
<string name="rich_text_editor_composer_placeholder">"Meddelande …"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Okrypterat meddelande …"</string>
<string name="rich_text_editor_create_link">"Skapa en länk"</string>
<string name="rich_text_editor_edit_link">"Redigera länk"</string>
<string name="rich_text_editor_format_action">"%1$s, läge: %2$s"</string>
<string name="rich_text_editor_format_bold">"Använd fetstil"</string>
<string name="rich_text_editor_format_italic">"Använd kursiv stil"</string>
<string name="rich_text_editor_format_state_disabled">"inaktiverad"</string>
<string name="rich_text_editor_format_state_off">"av"</string>
<string name="rich_text_editor_format_state_on">"på"</string>
<string name="rich_text_editor_format_strikethrough">"Använda genomstryken stil"</string>
<string name="rich_text_editor_format_underline">"Använd understruken stil"</string>
<string name="rich_text_editor_full_screen_toggle">"Växla helskärmsläge"</string>
<string name="rich_text_editor_indent">"Gör indrag"</string>
<string name="rich_text_editor_inline_code">"Använd kodformat i löptext"</string>
<string name="rich_text_editor_link">"Ange länk"</string>
<string name="rich_text_editor_numbered_list">"Växla numrerad lista"</string>
<string name="rich_text_editor_open_compose_options">"Öppna skrivalternativ"</string>
<string name="rich_text_editor_quote">"Växla citat"</string>
<string name="rich_text_editor_remove_link">"Ta bort länk"</string>
<string name="rich_text_editor_unindent">"Ta bort indrag"</string>
<string name="rich_text_editor_url_placeholder">"Länk"</string>
<string name="screen_media_upload_preview_caption_warning">"Bildtexter kanske inte är synliga för personer som använder äldre appar."</string>
<string name="screen_room_voice_message_tooltip">"Håll för att spela in"</string>
</resources>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Ek ekle"</string>
<string name="rich_text_editor_bullet_list">"Madde işaretli listeyi aç/kapat"</string>
<string name="rich_text_editor_close_formatting_options">"İptal et ve biçimlendirme seçeneklerini kapat"</string>
<string name="rich_text_editor_code_block">"Kod Bloğunu Aç/Kapat"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Açıklama ekle"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Şifrelenmiş mesaj…"</string>
<string name="rich_text_editor_composer_placeholder">"Mesaj…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Şifrelenmemiş mesaj…"</string>
<string name="rich_text_editor_create_link">"Bir bağlantı oluştur"</string>
<string name="rich_text_editor_edit_link">"Bağlantıyı Düzenle"</string>
<string name="rich_text_editor_format_bold">"Kalın biçimi uygula"</string>
<string name="rich_text_editor_format_italic">"İtalik biçimi uygula"</string>
<string name="rich_text_editor_format_strikethrough">"Üstü çizili biçimi uygula"</string>
<string name="rich_text_editor_format_underline">"Altı çizili biçimi uygula"</string>
<string name="rich_text_editor_full_screen_toggle">"Tam ekran modunu aç/kapat"</string>
<string name="rich_text_editor_indent">"Girinti"</string>
<string name="rich_text_editor_inline_code">"Satır içi kod biçimini uygula"</string>
<string name="rich_text_editor_link">"Bağlantıyı ayarla"</string>
<string name="rich_text_editor_numbered_list">"Numaralı listeyi aç/kapat"</string>
<string name="rich_text_editor_open_compose_options">"Oluşturma seçeneklerini aç"</string>
<string name="rich_text_editor_quote">"Alıntıyı Aç/Kapat"</string>
<string name="rich_text_editor_remove_link">"Bağlantıyı kaldır"</string>
<string name="rich_text_editor_unindent">"Girintiyi kaldır"</string>
<string name="rich_text_editor_url_placeholder">"Bağlantı"</string>
<string name="screen_media_upload_preview_caption_warning">"Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir."</string>
<string name="screen_room_voice_message_tooltip">"Kaydetmek için basılı tut"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Додати вкладення"</string>
<string name="rich_text_editor_bullet_list">"Перемкнути маркований список"</string>
<string name="rich_text_editor_close_formatting_options">"Скасувати та закрити форматування тексту"</string>
<string name="rich_text_editor_code_block">"Перемкнути блок коду"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Необов\'язковий підпис…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Зашифроване повідомлення…"</string>
<string name="rich_text_editor_composer_placeholder">"Повідомлення…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Незашифроване повідомлення…"</string>
<string name="rich_text_editor_create_link">"Створити посилання"</string>
<string name="rich_text_editor_edit_link">"Редагувати посилання"</string>
<string name="rich_text_editor_format_action">"%1$s, стан: %2$s"</string>
<string name="rich_text_editor_format_bold">"Жирний формат"</string>
<string name="rich_text_editor_format_italic">"Курсивний формат"</string>
<string name="rich_text_editor_format_state_disabled">"вимкнено"</string>
<string name="rich_text_editor_format_state_off">"вимкнено"</string>
<string name="rich_text_editor_format_state_on">"увімкнено"</string>
<string name="rich_text_editor_format_strikethrough">"Застосувати формат закреслення"</string>
<string name="rich_text_editor_format_underline">"Застосувати формат підкреслення"</string>
<string name="rich_text_editor_full_screen_toggle">"Перемкнути повноекранний режим"</string>
<string name="rich_text_editor_indent">"Відступ"</string>
<string name="rich_text_editor_inline_code">"Застосувати вбудований формат коду"</string>
<string name="rich_text_editor_link">"Установити посилання"</string>
<string name="rich_text_editor_numbered_list">"Перемкнути нумерований список"</string>
<string name="rich_text_editor_open_compose_options">"Відкрити параметри складання"</string>
<string name="rich_text_editor_quote">"Перемкнути цитату"</string>
<string name="rich_text_editor_remove_link">"Видалити посилання"</string>
<string name="rich_text_editor_unindent">"Без відступу"</string>
<string name="rich_text_editor_url_placeholder">"Посилання"</string>
<string name="screen_media_upload_preview_caption_warning">"Користувачі старих застосунків можуть не бачити підписи."</string>
<string name="screen_room_voice_message_tooltip">"Затисніть, щоб записати"</string>
</resources>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"منسلکہ شامل کریں"</string>
<string name="rich_text_editor_bullet_list">"گولئی فہرست تبدیل کریں"</string>
<string name="rich_text_editor_close_formatting_options">"ٹیکسٹ فارمیٹنگ کو منسوخ اور بند کریں۔"</string>
<string name="rich_text_editor_code_block">"رمز بلاک تبدیل کریں"</string>
<string name="rich_text_editor_composer_placeholder">"پیغام…"</string>
<string name="rich_text_editor_create_link">"ایک ربط بنائیں"</string>
<string name="rich_text_editor_edit_link">"ربط میں ترمیم کریں"</string>
<string name="rich_text_editor_format_bold">"جلی وضع لاگو کریں"</string>
<string name="rich_text_editor_format_italic">"ترچھی وضع لاگو کریں"</string>
<string name="rich_text_editor_format_strikethrough">"مشطوب وضع لاگو کریں"</string>
<string name="rich_text_editor_format_underline">"مسطر وضع لاگو کریں"</string>
<string name="rich_text_editor_full_screen_toggle">"مکمل نمایشگر وضع تبدیل کریں"</string>
<string name="rich_text_editor_indent">"حاشیہ"</string>
<string name="rich_text_editor_inline_code">"درخط رمز وضع تبدیل کریں"</string>
<string name="rich_text_editor_link">"ربط متعین کریں"</string>
<string name="rich_text_editor_numbered_list">"عددی فہرست تبدیل کریں"</string>
<string name="rich_text_editor_open_compose_options">"تحریر کے اختیارات کھولیں"</string>
<string name="rich_text_editor_quote">"حوالہ تبدیل کریں"</string>
<string name="rich_text_editor_remove_link">"ربط ہٹائیں"</string>
<string name="rich_text_editor_unindent">"غیر حاشیہ"</string>
<string name="rich_text_editor_url_placeholder">"ربط"</string>
<string name="screen_room_voice_message_tooltip">"ثبت کرنے کیلئے دبا کر رکھیں"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Biriktirma qo\'shing"</string>
<string name="rich_text_editor_bullet_list">"Belgilar roʻyxatini almashtirish"</string>
<string name="rich_text_editor_close_formatting_options">"Formatlash parametrlarini yoping"</string>
<string name="rich_text_editor_code_block">"Kod blokini almashtirish"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Taglavha kiritish"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Shifrlangan xabar…"</string>
<string name="rich_text_editor_composer_placeholder">"Xabar…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Shifrlanmagan xabar…"</string>
<string name="rich_text_editor_create_link">"Havola yarating"</string>
<string name="rich_text_editor_edit_link">"Havolani tahrirlash"</string>
<string name="rich_text_editor_format_action">"%1$s, holat: %2$s"</string>
<string name="rich_text_editor_format_bold">"Qalin formatni qo\'llang"</string>
<string name="rich_text_editor_format_italic">"Kursiv formatini qo\'llang"</string>
<string name="rich_text_editor_format_state_disabled">"oʻchirilgan"</string>
<string name="rich_text_editor_format_state_off">"o\'chiq"</string>
<string name="rich_text_editor_format_state_on">"yoniq"</string>
<string name="rich_text_editor_format_strikethrough">"Chizilgan formatni qo\'llash"</string>
<string name="rich_text_editor_format_underline">"Pastki chiziq formatini qo\'llang"</string>
<string name="rich_text_editor_full_screen_toggle">"Toʻliq ekran rejimiga oʻtish"</string>
<string name="rich_text_editor_indent">"Paragraf"</string>
<string name="rich_text_editor_inline_code">"Koq formatini mos ravishda qo\'shing"</string>
<string name="rich_text_editor_link">"Havolani o\'rnatish"</string>
<string name="rich_text_editor_numbered_list">"Raqamlangan roʻyxatni almashtirish"</string>
<string name="rich_text_editor_open_compose_options">"Yozish parametrlarini oching"</string>
<string name="rich_text_editor_quote">"Iqtibosni almashtirish"</string>
<string name="rich_text_editor_remove_link">"Havolani olib tashlang"</string>
<string name="rich_text_editor_unindent">"Paragrafni bekor qilish"</string>
<string name="rich_text_editor_url_placeholder">"Havola"</string>
<string name="screen_media_upload_preview_caption_warning">"Taglavhalar eski ilovalardan foydalanuvchilarga korinmasligi mumkin."</string>
<string name="screen_room_voice_message_tooltip">"Yozib olish uchun bosib turing"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"新增附件"</string>
<string name="rich_text_editor_bullet_list">"切換項目編號"</string>
<string name="rich_text_editor_close_formatting_options">"取消並關閉關閉文字格式化"</string>
<string name="rich_text_editor_code_block">"切換程式碼區塊"</string>
<string name="rich_text_editor_composer_caption_placeholder">"新增標題"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"已加密的訊息……"</string>
<string name="rich_text_editor_composer_placeholder">"訊息"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"未加密的訊息……"</string>
<string name="rich_text_editor_create_link">"建立連結"</string>
<string name="rich_text_editor_edit_link">"編輯連結"</string>
<string name="rich_text_editor_format_action">"%1$s,狀態:%2$s"</string>
<string name="rich_text_editor_format_bold">"套用粗體"</string>
<string name="rich_text_editor_format_italic">"套用斜體"</string>
<string name="rich_text_editor_format_state_disabled">"已停用"</string>
<string name="rich_text_editor_format_state_off">"關閉"</string>
<string name="rich_text_editor_format_state_on">"開啟"</string>
<string name="rich_text_editor_format_strikethrough">"套用刪除線"</string>
<string name="rich_text_editor_format_underline">"套用底線"</string>
<string name="rich_text_editor_full_screen_toggle">"切換全螢幕模式"</string>
<string name="rich_text_editor_indent">"增加縮排"</string>
<string name="rich_text_editor_inline_code">"套用行內程式碼"</string>
<string name="rich_text_editor_link">"設定連結"</string>
<string name="rich_text_editor_numbered_list">"切換數字編號"</string>
<string name="rich_text_editor_open_compose_options">"開啟撰寫選項"</string>
<string name="rich_text_editor_quote">"切換引用"</string>
<string name="rich_text_editor_remove_link">"移除連結"</string>
<string name="rich_text_editor_unindent">"減少縮排"</string>
<string name="rich_text_editor_url_placeholder">"連結"</string>
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
<string name="screen_room_voice_message_tooltip">"按住錄音"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"添加附件"</string>
<string name="rich_text_editor_bullet_list">"切换符号列表"</string>
<string name="rich_text_editor_close_formatting_options">"取消并关闭文本格式"</string>
<string name="rich_text_editor_code_block">"切换代码块"</string>
<string name="rich_text_editor_composer_caption_placeholder">"可选的标题……"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"加密信息…"</string>
<string name="rich_text_editor_composer_placeholder">"消息…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"未加密的消息…"</string>
<string name="rich_text_editor_create_link">"创建链接"</string>
<string name="rich_text_editor_edit_link">"编辑链接"</string>
<string name="rich_text_editor_format_action">"%1$s,状态:%2$s"</string>
<string name="rich_text_editor_format_bold">"应用粗体格式"</string>
<string name="rich_text_editor_format_italic">"应用斜体格式"</string>
<string name="rich_text_editor_format_state_disabled">"已禁用"</string>
<string name="rich_text_editor_format_state_off">"关"</string>
<string name="rich_text_editor_format_state_on">"开"</string>
<string name="rich_text_editor_format_strikethrough">"应用删除线格式"</string>
<string name="rich_text_editor_format_underline">"应用下划线格式"</string>
<string name="rich_text_editor_full_screen_toggle">"切换全屏模式"</string>
<string name="rich_text_editor_indent">"缩进"</string>
<string name="rich_text_editor_inline_code">"应用行内代码格式"</string>
<string name="rich_text_editor_link">"设置链接"</string>
<string name="rich_text_editor_numbered_list">"切换编号列表"</string>
<string name="rich_text_editor_open_compose_options">"打开撰写选项"</string>
<string name="rich_text_editor_quote">"切换引用"</string>
<string name="rich_text_editor_remove_link">"删除链接"</string>
<string name="rich_text_editor_unindent">"取消缩进"</string>
<string name="rich_text_editor_url_placeholder">"链接"</string>
<string name="screen_media_upload_preview_caption_warning">"使用旧版应用程序的用户可能无法看到字幕。"</string>
<string name="screen_room_voice_message_tooltip">"按住录制"</string>
</resources>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Add attachment"</string>
<string name="rich_text_editor_bullet_list">"Toggle bullet list"</string>
<string name="rich_text_editor_close_formatting_options">"Cancel and close text formatting"</string>
<string name="rich_text_editor_code_block">"Toggle code block"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Add a caption"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Encrypted message…"</string>
<string name="rich_text_editor_composer_placeholder">"Message…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Unencrypted message…"</string>
<string name="rich_text_editor_create_link">"Create a link"</string>
<string name="rich_text_editor_edit_link">"Edit link"</string>
<string name="rich_text_editor_format_action">"%1$s, state: %2$s"</string>
<string name="rich_text_editor_format_bold">"Apply bold format"</string>
<string name="rich_text_editor_format_italic">"Apply italic format"</string>
<string name="rich_text_editor_format_state_disabled">"disabled"</string>
<string name="rich_text_editor_format_state_off">"off"</string>
<string name="rich_text_editor_format_state_on">"on"</string>
<string name="rich_text_editor_format_strikethrough">"Apply strikethrough format"</string>
<string name="rich_text_editor_format_underline">"Apply underline format"</string>
<string name="rich_text_editor_full_screen_toggle">"Toggle full screen mode"</string>
<string name="rich_text_editor_indent">"Indent"</string>
<string name="rich_text_editor_inline_code">"Apply inline code format"</string>
<string name="rich_text_editor_link">"Set link"</string>
<string name="rich_text_editor_numbered_list">"Toggle numbered list"</string>
<string name="rich_text_editor_open_compose_options">"Open compose options"</string>
<string name="rich_text_editor_quote">"Toggle quote"</string>
<string name="rich_text_editor_remove_link">"Remove link"</string>
<string name="rich_text_editor_unindent">"Unindent"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_room_voice_message_tooltip">"Hold to record"</string>
</resources>
@@ -0,0 +1,192 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.compound.theme.ElementTheme
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.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.impl.mentions.aMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
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.libraries.textcomposer.model.aMarkdownTextEditorState
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 state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
rule.setMarkdownTextInput(state = state)
var editor: EditText? = null
rule.activityRule.scenario.onActivity {
editor = it.findEditor()
state.insertSuggestion(
ResolvedSuggestion.Member(roomMember = aRoomMember()),
aMentionSpanProvider(permalinkParser),
)
}
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(),
onTyping: (Boolean) -> Unit = {},
onSuggestionReceived: (Suggestion?) -> Unit = {},
) {
setContent {
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus)
MarkdownTextInput(
state = state,
placeholder = "Placeholder",
placeholderColor = ElementTheme.colors.textSecondary,
onTyping = onTyping,
onReceiveSuggestion = onSuggestionReceived,
richTextEditorStyle = style,
onSelectRichContent = null,
)
}
}
private fun ComponentActivity.findEditor(): EditText {
return window.decorView.findViewWithTag(TestTags.plainTextEditor.value)
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.impl.mentions
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.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.tests.testutils.WarmUpRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class IntentionalMentionSpanProviderTest {
@JvmField @Rule
val warmUpRule = WarmUpRule()
private val permalinkParser = FakePermalinkParser()
private val mentionSpanProvider = aMentionSpanProvider(permalinkParser)
@Test
fun `getting mention span for a user returns a MentionSpan of type USER`() {
permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}")
assertThat(mentionSpan?.type).isInstanceOf(MentionType.User::class.java)
val userType = mentionSpan?.type as MentionType.User
assertThat(userType.userId).isEqualTo(A_USER_ID)
}
@Test
fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() {
permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY))
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#")
assertThat(mentionSpan?.type).isEqualTo(MentionType.Everyone)
}
@Test
fun `getting mention span for a room returns a MentionSpan of type ROOM`() {
permalinkParser.givenResult(
PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(),
)
)
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
assertThat(mentionSpan?.type).isInstanceOf(MentionType.Room::class.java)
val roomType = mentionSpan?.type as MentionType.Room
assertThat(roomType.roomIdOrAlias).isEqualTo(RoomAlias("#room:matrix.org").toRoomIdOrAlias())
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.impl.mentions
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
import io.element.android.libraries.textcomposer.mentions.DefaultMentionSpanFormatter
import io.element.android.libraries.textcomposer.mentions.MentionType
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MentionSpanFormatterTest {
private val roomMemberProfilesCache = RoomMemberProfilesCache()
private val roomNamesCache = RoomNamesCache()
private val formatter = DefaultMentionSpanFormatter(
roomMemberProfilesCache = roomMemberProfilesCache,
roomNamesCache = roomNamesCache,
)
@Test
fun `formatDisplayText - formats user mention with empty cache`() = runTest {
val userId = A_USER_ID
val mentionType = MentionType.User(userId)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo(userId.value)
}
@Test
fun `formatDisplayText - formats user mention with filled cache`() = runTest {
val userId = A_USER_ID
val roomMember = aRoomMember(userId, displayName = "alice")
roomMemberProfilesCache.replace(listOf(roomMember))
val mentionType = MentionType.User(userId)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("@alice")
}
@Test
fun `formatDisplayText - formats room mention with empty cache`() = runTest {
val roomAlias = A_ROOM_ALIAS
val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo(roomAlias.value)
}
@Test
fun `formatDisplayText - formats room mention with filled cache`() = runTest {
val roomAlias = A_ROOM_ALIAS
val roomSummary = aRoomSummary(
canonicalAlias = roomAlias,
name = "my room"
)
roomNamesCache.replace(listOf(roomSummary))
val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("#my room")
}
@Test
fun `formatDisplayText - formats room mention with room id and empty cache`() = runTest {
val roomId = A_ROOM_ID
val mentionType = MentionType.Room(roomId.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo(roomId.value)
}
@Test
fun `formatDisplayText - formats room mention with room id and filled cache`() = runTest {
val roomId = A_ROOM_ID
val roomSummary = aRoomSummary(
roomId = roomId,
name = "my room"
)
roomNamesCache.replace(listOf(roomSummary))
val mentionType = MentionType.Room(roomId.toRoomIdOrAlias())
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("#my room")
}
@Test
fun `formatDisplayText - formats message mention with empty cache`() = runTest {
val roomId = A_ROOM_ID
val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("💬 > ${roomId.value}")
}
@Test
fun `formatDisplayText - formats message mention with filled cache`() = runTest {
val roomId = A_ROOM_ID
val roomSummary = aRoomSummary(
roomId = roomId,
name = "my room"
)
roomNamesCache.replace(listOf(roomSummary))
val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID)
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("💬 > #my room")
}
@Test
fun `formatDisplayText - formats everyone mention`() = runTest {
val mentionType = MentionType.Everyone
val result = formatter.formatDisplayText(mentionType)
assertThat(result.toString()).isEqualTo("@room")
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.textcomposer.impl.mentions
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import io.element.android.libraries.textcomposer.mentions.MentionType
fun aMentionSpanProvider(
permalinkParser: PermalinkParser = FakePermalinkParser(),
mentionSpanFormatter: MentionSpanFormatter = object : MentionSpanFormatter {
override fun formatDisplayText(mentionType: MentionType): CharSequence {
return mentionType.toString()
}
},
mentionSpanTheme: MentionSpanTheme = MentionSpanTheme(A_USER_ID),
): MentionSpanProvider {
return MentionSpanProvider(
permalinkParser = permalinkParser,
mentionSpanFormatter = mentionSpanFormatter,
mentionSpanTheme = mentionSpanTheme,
)
}
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.libraries.matrix.test.A_ROOM_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.impl.mentions.aMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionType
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MarkdownTextEditorStateTest {
@Test
fun `insertMention - room alias - getMentions return empty list`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val suggestion = aRoomAliasSuggestion()
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(suggestion, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `insertSuggestion - room alias - with member but failed PermalinkBuilder result`() {
val state = aMarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "")
}
val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
}
@Test
fun `insertSuggestion - room alias`() {
val state = aMarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "")
}
val suggestion = aRoomAliasSuggestion()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(suggestion, mentionSpanProvider)
}
@Test
fun `insertSuggestion - with no currentMentionSuggestion does nothing`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val member = aRoomMember()
val mention = ResolvedSuggestion.Member(member)
val mentionSpanProvider = aMentionSpanProvider()
state.insertSuggestion(mention, mentionSpanProvider)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `insertSuggestion - with member`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(mention, mentionSpanProvider)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId)
}
@Test
fun `insertSuggestion - with @room`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val mention = ResolvedSuggestion.AtRoom
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertSuggestion(mention, mentionSpanProvider)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
}
@Test
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
val text = "No mentions here"
val state = aMarkdownTextEditorState(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(
permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") },
permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/$it") },
)
val state = aMarkdownTextEditorState(initialText = text, initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo(
"Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" +
" and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)"
)
}
@Test
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java)
}
private fun aMarkdownTextWithMentions(): CharSequence {
val userMentionSpan = MentionSpan(MentionType.User(UserId("@alice:matrix.org")))
val atRoomMentionSpan = MentionSpan(MentionType.Everyone)
val roomMentionSpan = MentionSpan(MentionType.Room(RoomAlias("#room:domain.org").toRoomIdOrAlias()))
return buildSpannedString {
append("Hello ")
inSpans(userMentionSpan) {
append("@")
}
append(" and everyone in ")
inSpans(atRoomMentionSpan) {
append("@")
}
append(" and a room ")
inSpans(roomMentionSpan) {
append("#room:domain.org")
}
}
}
private fun aRoomAliasSuggestion(): ResolvedSuggestion.Alias {
return ResolvedSuggestion.Alias(
roomAlias = A_ROOM_ALIAS,
roomId = A_ROOM_ID,
roomName = null,
roomAvatarUrl = null
)
}
}
+2
View File
@@ -0,0 +1,2 @@
# Built application files
*.aar
@@ -0,0 +1,3 @@
configurations.maybeCreate("default")
artifacts.add("default", file("library.aar"))
artifacts.add("default", file("library-compose.aar"))