First Commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+74
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+168
@@ -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)
|
||||
)
|
||||
}
|
||||
+85
@@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+54
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+965
@@ -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,
|
||||
)
|
||||
+228
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+126
@@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+98
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+125
@@ -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)
|
||||
}
|
||||
}
|
||||
+218
@@ -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())
|
||||
}
|
||||
+39
@@ -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)
|
||||
}
|
||||
+63
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+209
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+117
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+106
@@ -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)
|
||||
}
|
||||
+47
@@ -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
|
||||
}
|
||||
}
|
||||
+195
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
@@ -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)"
|
||||
}
|
||||
}
|
||||
+165
@@ -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()
|
||||
}
|
||||
}
|
||||
+75
@@ -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"
|
||||
}
|
||||
}
|
||||
+125
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+287
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -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 }
|
||||
+35
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+66
@@ -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,
|
||||
)
|
||||
}
|
||||
+165
@@ -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) }
|
||||
}
|
||||
+17
@@ -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>,
|
||||
)
|
||||
+63
@@ -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
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+34
@@ -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 d’anciennes 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 ko‘rinmasligi 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>
|
||||
+192
@@ -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)
|
||||
}
|
||||
}
|
||||
+61
@@ -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())
|
||||
}
|
||||
}
|
||||
+136
@@ -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")
|
||||
}
|
||||
}
|
||||
+33
@@ -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,
|
||||
)
|
||||
}
|
||||
+187
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user