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

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

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-05-21 13:58:53 +02:00
committed by GitHub
parent 0e05a0e4ed
commit 902dd24e72
94 changed files with 1554 additions and 524 deletions

View File

@@ -2,7 +2,7 @@ appId: ${MAESTRO_APP_ID}
---
- takeScreenshot: build/maestro/510-Timeline
- tapOn:
id: "rich_text_editor"
id: "text_editor"
- inputText: "Hello world!"
- tapOn: "Send"
- hideKeyboard

View File

@@ -34,7 +34,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
text: "Advanced settings"
- assertVisible: "Rich text editor"
- assertVisible: "View source"
- back
- tapOn:

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appconfig
object MessageComposerConfig {
/**
* Enable the rich text editing in the composer.
*/
const val ENABLE_RICH_TEXT_EDITING = true
}

1
changelog.d/2840.feature Normal file
View File

@@ -0,0 +1 @@
Add plain text editor based on Markdown input.

View File

@@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
@@ -66,7 +67,6 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -113,7 +113,6 @@ class MessagesPresenter @AssistedInject constructor(
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper,
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagsService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider,
@Assisted private val navigator: MessagesNavigator,
@@ -171,17 +170,15 @@ class MessagesPresenter @AssistedInject constructor(
val inviteProgress = remember { mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized) }
var showReinvitePrompt by remember { mutableStateOf(false) }
LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) {
LaunchedEffect(hasDismissedInviteDialog, composerState.textEditorState.hasFocus(), syncUpdateFlow.value) {
withContext(dispatchers.io) {
showReinvitePrompt = !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L
showReinvitePrompt = !hasDismissedInviteDialog && composerState.textEditorState.hasFocus() && room.isDirect && room.activeMemberCount == 1L
}
}
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
var enableVoiceMessages by remember { mutableStateOf(false) }
LaunchedEffect(featureFlagsService) {
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
@@ -194,7 +191,7 @@ class MessagesPresenter @AssistedInject constructor(
action = event.action,
targetEvent = event.event,
composerState = composerState,
enableTextFormatting = enableTextFormatting,
enableTextFormatting = composerState.showTextFormatting,
timelineState = timelineState,
)
}
@@ -239,7 +236,7 @@ class MessagesPresenter @AssistedInject constructor(
snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting,
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName,
callState = callState,

View File

@@ -45,6 +45,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@@ -99,9 +100,9 @@ fun aMessagesState(
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = true,
composerState: MessageComposerState = aMessageComposerState(
richTextEditorState = aRichTextEditorState(initialText = "Hello", initialFocus = true),
isFullScreen = false,
mode = MessageComposerMode.Normal,
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
isFullScreen = false,
mode = MessageComposerMode.Normal,
),
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState(

View File

@@ -362,7 +362,7 @@ private fun MessagesViewContent(
// Any state change that should trigger a height size should be added to the list of remembered values here.
val sheetResizeContentKey = remember { mutableIntStateOf(0) }
LaunchedEffect(
state.composerState.richTextEditorState.lineCount,
state.composerState.textEditorState.lineCount,
state.composerState.showTextFormatting,
) {
sheetResizeContentKey.intValue = Random.nextInt()
@@ -439,7 +439,6 @@ private fun MessagesViewComposerBottomSheetContents(
state = state.composerState,
voiceMessageState = state.voiceMessageComposerState,
subcomposing = subcomposing,
enableTextFormatting = state.enableTextFormatting,
enableVoiceMessages = state.enableVoiceMessages,
modifier = Modifier.fillMaxWidth(),
)

View File

@@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -51,8 +52,8 @@ fun MentionSuggestionsPickerView(
roomId: RoomId,
roomName: String?,
roomAvatarData: AvatarData?,
memberSuggestions: ImmutableList<MentionSuggestion>,
onSuggestionSelected: (MentionSuggestion) -> Unit,
memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@@ -62,8 +63,8 @@ fun MentionSuggestionsPickerView(
memberSuggestions,
key = { suggestion ->
when (suggestion) {
is MentionSuggestion.Room -> "@room"
is MentionSuggestion.Member -> suggestion.roomMember.userId.value
is ResolvedMentionSuggestion.AtRoom -> "@room"
is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value
}
}
) {
@@ -84,18 +85,18 @@ fun MentionSuggestionsPickerView(
@Composable
private fun RoomMemberSuggestionItemView(
memberSuggestion: MentionSuggestion,
memberSuggestion: ResolvedMentionSuggestion,
roomId: String,
roomName: String?,
roomAvatar: AvatarData?,
onSuggestionSelected: (MentionSuggestion) -> Unit,
onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val avatarSize = AvatarSize.TimelineRoom
val avatarData = when (memberSuggestion) {
is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is MentionSuggestion.Member -> AvatarData(
is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
is ResolvedMentionSuggestion.Member -> AvatarData(
memberSuggestion.roomMember.userId.value,
memberSuggestion.roomMember.displayName,
memberSuggestion.roomMember.avatarUrl,
@@ -103,13 +104,13 @@ private fun RoomMemberSuggestionItemView(
)
}
val title = when (memberSuggestion) {
is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName
is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title)
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName
}
val subtitle = when (memberSuggestion) {
is MentionSuggestion.Room -> "@room"
is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
is ResolvedMentionSuggestion.AtRoom -> "@room"
is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value
}
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
@@ -159,9 +160,9 @@ internal fun MentionSuggestionsPickerViewPreview() {
roomName = "Room",
roomAvatarData = null,
memberSuggestions = persistentListOf(
MentionSuggestion.Room,
MentionSuggestion.Member(roomMember),
MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
ResolvedMentionSuggestion.AtRoom,
ResolvedMentionSuggestion.Member(roomMember),
ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
),
onSuggestionSelected = {}
)

View File

@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
@@ -45,7 +46,7 @@ object MentionSuggestionsProcessor {
roomMembersState: MatrixRoomMembersState,
currentUserId: UserId,
canSendRoomMention: suspend () -> Boolean,
): List<MentionSuggestion> {
): List<ResolvedMentionSuggestion> {
val members = roomMembersState.roomMembers()
return when {
members.isNullOrEmpty() || suggestion == null -> {
@@ -78,7 +79,7 @@ object MentionSuggestionsProcessor {
roomMembers: List<RoomMember>?,
currentUserId: UserId,
canSendRoomMention: Boolean,
): List<MentionSuggestion> {
): List<ResolvedMentionSuggestion> {
return if (roomMembers.isNullOrEmpty()) {
emptyList()
} else {
@@ -96,10 +97,10 @@ object MentionSuggestionsProcessor {
.filterUpTo(MAX_BATCH_ITEMS) { member ->
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
}
.map(MentionSuggestion::Member)
.map(ResolvedMentionSuggestion::Member)
if ("room".contains(query) && canSendRoomMention) {
listOf(MentionSuggestion.Room) + matchingMembers
listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers
} else {
matchingMembers
}

View File

@@ -18,15 +18,14 @@ package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
@Immutable
sealed interface MessageComposerEvents {
data object ToggleFullScreenState : MessageComposerEvents
data class SendMessage(val message: Message) : MessageComposerEvents
data object SendMessage : MessageComposerEvents
data class SendUri(val uri: Uri) : MessageComposerEvents
data object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
@@ -45,5 +44,5 @@ sealed interface MessageComposerEvents {
data class Error(val error: Throwable) : MessageComposerEvents
data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents
data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents
}

View File

@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
import android.Manifest
import android.annotation.SuppressLint
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.media3.common.MimeTypes
@@ -36,7 +38,6 @@ import androidx.media3.common.util.UnstableApi
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.preferences.api.store.SessionPreferencesStore
@@ -59,17 +60,21 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
@@ -108,12 +113,27 @@ class MessageComposerPresenter @Inject constructor(
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
// Used to disable some UI related elements in tests
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var isTesting: Boolean = false
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var showTextFormatting: Boolean by mutableStateOf(false)
@OptIn(FlowPreview::class)
@SuppressLint("UnsafeOptInUsageError")
@Composable
override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
// Initially disabled so we don't set focus and text twice
var applyFormattingModeChanges by remember { mutableStateOf(false) }
val richTextEditorState = richTextEditorStateFactory.remember()
if (isTesting) {
richTextEditorState.isReadyToProcessActions = true
}
val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null, initialFocus = false) }
var isMentionsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
@@ -149,18 +169,20 @@ class MessageComposerPresenter @Inject constructor(
val isFullScreen = rememberSaveable {
mutableStateOf(false)
}
val richTextEditorState = richTextEditorStateFactory.create()
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
var showTextFormatting: Boolean by remember { mutableStateOf(false) }
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit ->
richTextEditorState.setHtml(modeValue.defaultContent)
if (showTextFormatting) {
richTextEditorState.setHtml(modeValue.defaultContent)
} else {
markdownTextEditorState.text.update(modeValue.defaultContent, true)
}
else -> Unit
}
}
@@ -188,7 +210,7 @@ class MessageComposerPresenter @Inject constructor(
}
}
val memberSuggestions = remember { mutableStateListOf<MentionSuggestion>() }
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() }
LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = currentSessionIdHolder.current
@@ -229,22 +251,69 @@ class MessageComposerPresenter @Inject constructor(
}
}
val textEditorState by rememberUpdatedState(
if (showTextFormatting) {
TextEditorState.Rich(richTextEditorState)
} else {
TextEditorState.Markdown(markdownTextEditorState)
}
)
LaunchedEffect(showTextFormatting) {
if (!applyFormattingModeChanges) {
applyFormattingModeChanges = true
return@LaunchedEffect
}
if (showTextFormatting) {
val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
richTextEditorState.setMarkdown(markdown)
richTextEditorState.requestFocus()
} else {
val markdown = richTextEditorState.messageMarkdown
markdownTextEditorState.text.update(markdown, true)
// Give some time for the focus of the previous editor to be cleared
delay(100)
markdownTextEditorState.requestFocusAction()
}
}
val mentionSpanProvider = if (isTesting) {
null
} else {
rememberMentionSpanProvider(
currentUserId = room.sessionId,
permalinkParser = permalinkParser,
)
}
fun handleEvents(event: MessageComposerEvents) {
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> {
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
localCoroutineScope.launch {
richTextEditorState.setHtml("")
textEditorState.reset()
}
}
messageComposerContext.composerMode = MessageComposerMode.Normal
}
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
message = event.message,
updateComposerMode = { messageComposerContext.composerMode = it },
richTextEditorState = richTextEditorState,
)
is MessageComposerEvents.SendMessage -> {
val html = if (showTextFormatting) {
richTextEditorState.messageHtml
} else {
null
}
val markdown = if (showTextFormatting) {
richTextEditorState.messageMarkdown
} else {
markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
}
appCoroutineScope.sendMessage(
message = Message(html = html, markdown = markdown),
updateComposerMode = { messageComposerContext.composerMode = it },
textEditorState = textEditorState,
)
}
is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment(
attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri(
@@ -335,15 +404,26 @@ class MessageComposerPresenter @Inject constructor(
}
is MessageComposerEvents.InsertMention -> {
localCoroutineScope.launch {
when (val mention = event.mention) {
is MentionSuggestion.Room -> {
richTextEditorState.insertAtRoomMentionAtSuggestion()
if (showTextFormatting) {
when (val mention = event.mention) {
is ResolvedMentionSuggestion.AtRoom -> {
richTextEditorState.insertAtRoomMentionAtSuggestion()
}
is ResolvedMentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
}
}
is MentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} else if (markdownTextEditorState.currentMentionSuggestion != null) {
mentionSpanProvider?.let {
markdownTextEditorState.insertMention(
mention = event.mention,
mentionSpanProvider = it,
permalinkBuilder = permalinkBuilder,
)
}
suggestionSearchTrigger.value = null
}
}
}
@@ -351,7 +431,7 @@ class MessageComposerPresenter @Inject constructor(
}
return MessageComposerState(
richTextEditorState = richTextEditorState,
textEditorState = textEditorState,
permalinkParser = permalinkParser,
isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode,
@@ -369,21 +449,26 @@ class MessageComposerPresenter @Inject constructor(
private fun CoroutineScope.sendMessage(
message: Message,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
richTextEditorState: RichTextEditorState,
textEditorState: TextEditorState,
) = launch {
val capturedMode = messageComposerContext.composerMode
val mentions = richTextEditorState.mentionsState?.let { state ->
buildList {
if (state.hasAtRoomMention) {
add(Mention.AtRoom)
}
for (userId in state.userIds) {
add(Mention.User(UserId(userId)))
}
val mentions = when (textEditorState) {
is TextEditorState.Rich -> {
textEditorState.richTextEditorState.mentionsState?.let { state ->
buildList {
if (state.hasAtRoomMention) {
add(Mention.AtRoom)
}
for (userId in state.userIds) {
add(Mention.User(UserId(userId)))
}
}
}.orEmpty()
}
}.orEmpty()
is TextEditorState.Markdown -> textEditorState.state.getMentions()
}
// Reset composer right away
richTextEditorState.setHtml("")
textEditorState.reset()
updateComposerMode(MessageComposerMode.Normal)
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions)

View File

@@ -19,16 +19,16 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.libraries.textcomposer.model.TextEditorState
import kotlinx.collections.immutable.ImmutableList
@Stable
data class MessageComposerState(
val richTextEditorState: RichTextEditorState,
val textEditorState: TextEditorState,
val permalinkParser: PermalinkParser,
val isFullScreen: Boolean,
val mode: MessageComposerMode,
@@ -37,12 +37,10 @@ data class MessageComposerState(
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<MentionSuggestion>,
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
val currentUserId: UserId,
val eventSink: (MessageComposerEvents) -> Unit,
) {
val hasFocus: Boolean = richTextEditorState.hasFocus
}
)
@Immutable
sealed interface AttachmentsState {

View File

@@ -17,13 +17,13 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.libraries.textcomposer.model.TextEditorState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -35,7 +35,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
}
fun aMessageComposerState(
richTextEditorState: RichTextEditorState = aRichTextEditorState(),
textEditorState: TextEditorState = TextEditorState.Rich(aRichTextEditorState()),
isFullScreen: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal,
showTextFormatting: Boolean = false,
@@ -43,9 +43,9 @@ fun aMessageComposerState(
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
memberSuggestions: ImmutableList<ResolvedMentionSuggestion> = persistentListOf(),
) = MessageComposerState(
richTextEditorState = richTextEditorState,
textEditorState = textEditorState,
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData = TODO()
},

View File

@@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMe
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@@ -44,13 +43,12 @@ internal fun MessageComposerView(
state: MessageComposerState,
voiceMessageState: VoiceMessageComposerState,
subcomposing: Boolean,
enableTextFormatting: Boolean,
enableVoiceMessages: Boolean,
modifier: Modifier = Modifier,
) {
val view = LocalView.current
fun sendMessage(message: Message) {
state.eventSink(MessageComposerEvents.SendMessage(message))
fun sendMessage() {
state.eventSink(MessageComposerEvents.SendMessage)
}
fun sendUri(uri: Uri) {
@@ -85,7 +83,7 @@ internal fun MessageComposerView(
val coroutineScope = rememberCoroutineScope()
fun onRequestFocus() {
coroutineScope.launch {
state.richTextEditorState.requestFocus()
state.textEditorState.requestFocus()
}
}
@@ -107,7 +105,7 @@ internal fun MessageComposerView(
TextComposer(
modifier = modifier,
state = state.richTextEditorState,
state = state.textEditorState,
voiceMessageState = voiceMessageState.voiceMessageState,
permalinkParser = state.permalinkParser,
subcomposing = subcomposing,
@@ -118,7 +116,6 @@ internal fun MessageComposerView(
onResetComposerMode = ::onCloseSpecialMode,
onAddAttachment = ::onAddAttachment,
onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
onVoiceRecorderEvent = onVoiceRecorderEvent,
onVoicePlayerEvent = onVoicePlayerEvent,
@@ -142,7 +139,6 @@ internal fun MessageComposerViewPreview(
modifier = Modifier.height(IntrinsicSize.Min),
state = state,
voiceMessageState = aVoiceMessageComposerState(),
enableTextFormatting = true,
enableVoiceMessages = true,
subcomposing = false,
)
@@ -150,7 +146,6 @@ internal fun MessageComposerViewPreview(
modifier = Modifier.height(200.dp),
state = state,
voiceMessageState = aVoiceMessageComposerState(),
enableTextFormatting = true,
enableVoiceMessages = true,
subcomposing = false,
)
@@ -167,7 +162,6 @@ internal fun MessageComposerViewVoicePreview(
modifier = Modifier.height(IntrinsicSize.Min),
state = aMessageComposerState(),
voiceMessageState = state,
enableTextFormatting = true,
enableVoiceMessages = true,
subcomposing = false,
)

View File

@@ -25,13 +25,13 @@ import javax.inject.Inject
interface RichTextEditorStateFactory {
@Composable
fun create(): RichTextEditorState
fun remember(): RichTextEditorState
}
@ContributesBinding(AppScope::class)
class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory {
@Composable
override fun create(): RichTextEditorState {
override fun remember(): RichTextEditorState {
return rememberRichTextEditorState()
}
}

View File

@@ -538,7 +538,7 @@ class MessagesPresenterTest {
// Initially the composer doesn't have focus, so we don't show the alert
assertThat(initialState.showReinvitePrompt).isFalse()
// When the input field is focused we show the alert
initialState.composerState.richTextEditorState.requestFocus()
initialState.composerState.textEditorState.requestFocus()
val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state ->
state.showReinvitePrompt
}.last()
@@ -561,7 +561,7 @@ class MessagesPresenterTest {
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.richTextEditorState.requestFocus()
initialState.composerState.textEditorState.requestFocus()
val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse()
}
@@ -576,7 +576,7 @@ class MessagesPresenterTest {
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.showReinvitePrompt).isFalse()
initialState.composerState.richTextEditorState.requestFocus()
initialState.composerState.textEditorState.requestFocus()
val focusedState = awaitItem()
assertThat(focusedState.showReinvitePrompt).isFalse()
}
@@ -781,7 +781,7 @@ class MessagesPresenterTest {
): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val appPreferencesStore = InMemoryAppPreferencesStore(isRichTextEditorEnabled = true)
val appPreferencesStore = InMemoryAppPreferencesStore()
val sessionPreferencesStore = InMemorySessionPreferencesStore()
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
@@ -800,7 +800,10 @@ class MessagesPresenterTest {
permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(),
timelineController = TimelineController(matrixRoom),
)
).apply {
showTextFormatting = true
isTesting = true
}
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
this,
FakeVoiceRecorder(),
@@ -853,7 +856,6 @@ class MessagesPresenterTest {
messageSummaryFormatter = FakeMessageSummaryFormatter(),
navigator = navigator,
clipboardHelper = clipboardHelper,
appPreferencesStore = appPreferencesStore,
featureFlagsService = FakeFeatureFlagService(),
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,

View File

@@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -77,10 +76,11 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
@@ -127,7 +127,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
@@ -158,10 +158,10 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.richTextEditorState.setHtml(A_MESSAGE)
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
initialState.richTextEditorState.setHtml("")
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
initialState.textEditorState.setHtml(A_MESSAGE)
assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
initialState.textEditorState.setHtml("")
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
}
}
@@ -170,7 +170,7 @@ class MessageComposerPresenterTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.richTextEditorState.messageHtml) { state }
remember(state, state.textEditorState.messageHtml()) { state }
}.test {
var state = awaitFirstItem()
val mode = anEditMode()
@@ -178,11 +178,11 @@ class MessageComposerPresenterTest {
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
state = awaitItem()
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
state = backToNormalMode(state, skipCount = 1)
// The message that was being edited is cleared
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
}
}
@@ -197,7 +197,7 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
backToNormalMode(state)
}
}
@@ -213,11 +213,11 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
state.richTextEditorState.setHtml(A_REPLY)
state.textEditorState.setHtml(A_REPLY)
state = backToNormalMode(state)
// The message typed while replying is not cleared
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY)
}
}
@@ -232,25 +232,54 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
backToNormalMode(state)
}
}
@Test
fun `present - send message`() = runTest {
fun `present - send message with rich text enabled`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.richTextEditorState.messageHtml) { state }
remember(state, state.textEditorState.messageHtml()) { state }
}.test {
val initialState = awaitFirstItem()
initialState.richTextEditorState.setHtml(A_MESSAGE)
initialState.textEditorState.setHtml(A_MESSAGE)
val withMessageState = awaitItem()
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
val messageSentState = awaitItem()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
waitForPredicate { analyticsService.capturedEvents.size == 1 }
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.Text,
)
)
}
}
@Test
fun `present - send message with plain text enabled`() = runTest {
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("") })
val presenter = createPresenter(this, isRichTextEditorEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder)
remember(state, messageMarkdown) { state }
}.test {
val initialState = awaitFirstItem()
initialState.textEditorState.setMarkdown(A_MESSAGE)
val withMessageState = awaitItem()
assertThat(withMessageState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE)
assertThat(withMessageState.textEditorState.messageHtml()).isNull()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
val messageSentState = awaitItem()
assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("")
waitForPredicate { analyticsService.capturedEvents.size == 1 }
assertThat(analyticsService.capturedEvents).containsExactly(
Composer(
@@ -278,23 +307,23 @@ class MessageComposerPresenterTest {
)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.richTextEditorState.messageHtml) { state }
remember(state, state.textEditorState.messageHtml()) { state }
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
val mode = anEditMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
advanceUntilIdle()
@@ -328,23 +357,23 @@ class MessageComposerPresenterTest {
)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.richTextEditorState.messageHtml) { state }
remember(state, state.textEditorState.messageHtml()) { state }
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE)
assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE)
withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE)
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage()))
assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage)
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
advanceUntilIdle()
@@ -380,17 +409,17 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(initialState.textEditorState.messageHtml()).isEqualTo("")
val mode = aReplyMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
val state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
state.richTextEditorState.setHtml(A_REPLY)
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage()))
assertThat(state.textEditorState.messageHtml()).isEqualTo("")
state.textEditorState.setHtml(A_REPLY)
assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY)
state.eventSink.invoke(MessageComposerEvents.SendMessage)
val messageSentState = awaitItem()
assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("")
assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("")
advanceUntilIdle()
@@ -725,7 +754,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - ToggleTextFormatting toggles text formatting`() = runTest {
val presenter = createPresenter(this)
val presenter = createPresenter(this, isRichTextEditorEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -735,11 +764,12 @@ class MessageComposerPresenterTest {
val composerOptions = awaitItem()
assertThat(composerOptions.showAttachmentSourcePicker).isTrue()
composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true))
awaitItem() // composer options closed
skipItems(2) // composer options closed
val showTextFormatting = awaitItem()
assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse()
assertThat(showTextFormatting.showTextFormatting).isTrue()
showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false))
skipItems(1)
val finished = awaitItem()
assertThat(finished.showTextFormatting).isFalse()
}
@@ -781,19 +811,19 @@ class MessageComposerPresenterTest {
// An empty suggestion returns the room and joined members that are not the current user
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
assertThat(awaitItem().memberSuggestions)
.containsExactly(MentionSuggestion.Room, MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
.containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
// A suggestion containing a part of "room" will also return the room mention
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Room)
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.AtRoom)
// A non-empty suggestion will return those joined members whose user id matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(bob))
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(bob))
// A non-empty suggestion will return those joined members whose display name matches it
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(david))
assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(david))
// If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
@@ -803,7 +833,7 @@ class MessageComposerPresenterTest {
room.givenCanTriggerRoomNotification(Result.success(false))
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
assertThat(awaitItem().memberSuggestions)
.containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
.containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
// If room is a DM, `RoomMemberSuggestion.Room` is not returned
room.givenCanTriggerRoomNotification(Result.success(true))
@@ -844,7 +874,7 @@ class MessageComposerPresenterTest {
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
assertThat(awaitItem().memberSuggestions)
.containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david))
.containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david))
}
}
@@ -862,10 +892,10 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.richTextEditorState.setHtml("Hey @bo")
initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
initialState.textEditorState.setHtml("Hey @bo")
initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2))))
assertThat(initialState.richTextEditorState.messageHtml)
assertThat(initialState.textEditorState.messageHtml())
.isEqualTo("Hey <a href='https://matrix.to/#/${A_USER_ID_2.value}'>${A_USER_ID_2.value}</a>")
}
}
@@ -892,14 +922,14 @@ class MessageComposerPresenterTest {
// Check intentional mentions on message sent
val mentionUser1 = listOf(A_USER_ID.value)
initialState.richTextEditorState.mentionsState = MentionsState(
(initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
userIds = mentionUser1,
roomIds = emptyList(),
roomAliases = emptyList(),
hasAtRoomMention = false
)
initialState.richTextEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
initialState.textEditorState.setHtml(A_MESSAGE)
initialState.eventSink(MessageComposerEvents.SendMessage)
advanceUntilIdle()
@@ -908,14 +938,14 @@ class MessageComposerPresenterTest {
// Check intentional mentions on reply sent
initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode()))
val mentionUser2 = listOf(A_USER_ID_2.value)
awaitItem().richTextEditorState.mentionsState = MentionsState(
(awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
userIds = mentionUser2,
roomIds = emptyList(),
roomAliases = emptyList(),
hasAtRoomMention = false
)
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
initialState.eventSink(MessageComposerEvents.SendMessage)
advanceUntilIdle()
assert(replyMessageLambda)
@@ -926,14 +956,14 @@ class MessageComposerPresenterTest {
skipItems(1)
initialState.eventSink(MessageComposerEvents.SetMode(anEditMode()))
val mentionUser3 = listOf(A_USER_ID_3.value)
awaitItem().richTextEditorState.mentionsState = MentionsState(
(awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState(
userIds = mentionUser3,
roomIds = emptyList(),
roomAliases = emptyList(),
hasAtRoomMention = false
)
initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage()))
initialState.eventSink(MessageComposerEvents.SendMessage)
advanceUntilIdle()
assert(editMessageLambda)
@@ -949,7 +979,7 @@ class MessageComposerPresenterTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
val state = presenter.present()
remember(state, state.richTextEditorState.messageHtml) { state }
remember(state, state.textEditorState.messageHtml()) { state }
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri")))
@@ -1007,7 +1037,8 @@ class MessageComposerPresenterTest {
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
isRichTextEditorEnabled: Boolean = true,
) = MessageComposerPresenter(
coroutineScope,
room,
@@ -1025,7 +1056,10 @@ class MessageComposerPresenterTest {
permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room),
)
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
// Skip 2 item if Mentions feature is enabled, else 1
@@ -1043,7 +1077,10 @@ fun anEditMode(
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
private fun String.toMessage() = Message(
html = this,
markdown = this,
)
private suspend fun TextEditorState.setHtml(html: String) {
(this as? TextEditorState.Rich)?.richTextEditorState?.setHtml(html) ?: error("TextEditorState is not Rich")
}
private fun TextEditorState.setMarkdown(markdown: String) {
(this as? TextEditorState.Markdown)?.state?.text?.update(markdown, needsDisplaying = false) ?: error("TextEditorState is not Markdown")
}

View File

@@ -23,7 +23,7 @@ import io.element.android.wysiwyg.compose.rememberRichTextEditorState
class TestRichTextEditorStateFactory : RichTextEditorStateFactory {
@Composable
override fun create(): RichTextEditorState {
override fun remember(): RichTextEditorState {
return rememberRichTextEditorState("", fake = true)
}
}

View File

@@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced
import io.element.android.compound.theme.Theme
sealed interface AdvancedSettingsEvents {
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data object ChangeTheme : AdvancedSettingsEvents

View File

@@ -38,9 +38,6 @@ class AdvancedSettingsPresenter @Inject constructor(
@Composable
override fun present(): AdvancedSettingsState {
val localCoroutineScope = rememberCoroutineScope()
val isRichTextEditorEnabled by appPreferencesStore
.isRichTextEditorEnabledFlow()
.collectAsState(initial = false)
val isDeveloperModeEnabled by appPreferencesStore
.isDeveloperModeEnabledFlow()
.collectAsState(initial = false)
@@ -54,9 +51,6 @@ class AdvancedSettingsPresenter @Inject constructor(
var showChangeThemeDialog by remember { mutableStateOf(false) }
fun handleEvents(event: AdvancedSettingsEvents) {
when (event) {
is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch {
appPreferencesStore.setRichTextEditorEnabled(event.enabled)
}
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
appPreferencesStore.setDeveloperModeEnabled(event.enabled)
}
@@ -73,7 +67,6 @@ class AdvancedSettingsPresenter @Inject constructor(
}
return AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSharePresenceEnabled,
theme = theme,

View File

@@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced
import io.element.android.compound.theme.Theme
data class AdvancedSettingsState(
val isRichTextEditorEnabled: Boolean,
val isDeveloperModeEnabled: Boolean,
val isSharePresenceEnabled: Boolean,
val theme: Theme,

View File

@@ -23,7 +23,6 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
override val values: Sequence<AdvancedSettingsState>
get() = sequenceOf(
aAdvancedSettingsState(),
aAdvancedSettingsState(isRichTextEditorEnabled = true),
aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(showChangeThemeDialog = true),
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true),
@@ -31,12 +30,10 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
}
fun aAdvancedSettingsState(
isRichTextEditorEnabled: Boolean = false,
isDeveloperModeEnabled: Boolean = false,
isSendPublicReadReceiptsEnabled: Boolean = false,
showChangeThemeDialog: Boolean = false,
) = AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSendPublicReadReceiptsEnabled,
theme = Theme.System,

View File

@@ -57,18 +57,6 @@ fun AdvancedSettingsView(
state.eventSink(AdvancedSettingsEvents.ChangeTheme)
}
)
ListItem(
headlineContent = {
Text(text = stringResource(id = CommonStrings.common_rich_text_editor))
},
supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_rich_text_editor_description))
},
trailingContent = ListItemContent.Switch(
checked = state.isRichTextEditorEnabled,
),
onClick = { state.eventSink(AdvancedSettingsEvents.SetRichTextEditorEnabled(!state.isRichTextEditorEnabled)) }
)
ListItem(
headlineContent = {
Text(text = stringResource(id = CommonStrings.action_view_source))

View File

@@ -41,7 +41,6 @@ class AdvancedSettingsPresenterTest {
}.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.isDeveloperModeEnabled).isFalse()
assertThat(initialState.isRichTextEditorEnabled).isFalse()
assertThat(initialState.showChangeThemeDialog).isFalse()
assertThat(initialState.isSharePresenceEnabled).isTrue()
assertThat(initialState.theme).isEqualTo(Theme.System)
@@ -63,21 +62,6 @@ class AdvancedSettingsPresenterTest {
}
}
@Test
fun `present - rich text editor on off`() = runTest {
val presenter = createAdvancedSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitLastSequentialItem()
assertThat(initialState.isRichTextEditorEnabled).isFalse()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(true))
assertThat(awaitItem().isRichTextEditorEnabled).isTrue()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(false))
assertThat(awaitItem().isRichTextEditorEnabled).isFalse()
}
}
@Test
fun `present - share presence off on`() = runTest {
val presenter = createAdvancedSettingsPresenter()

View File

@@ -43,7 +43,7 @@ serialization_json = "1.6.3"
showkase = "1.0.2"
appyx = "1.4.0"
sqldelight = "2.0.2"
wysiwyg = "2.37.2"
wysiwyg = "2.37.3"
telephoto = "0.11.2"
# DI

View File

@@ -20,9 +20,9 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
class FakePermalinkBuilder(
private val result: () -> Result<String> = { Result.failure(Exception("Not implemented")) }
private val result: (UserId) -> Result<String> = { Result.failure(Exception("Not implemented")) }
) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> {
return result()
return result(userId)
}
}

View File

@@ -19,9 +19,6 @@ package io.element.android.features.preferences.api.store
import kotlinx.coroutines.flow.Flow
interface AppPreferencesStore {
suspend fun setRichTextEditorEnabled(enabled: Boolean)
fun isRichTextEditorEnabledFlow(): Flow<Boolean>
suspend fun setDeveloperModeEnabled(enabled: Boolean)
fun isDeveloperModeEnabledFlow(): Flow<Boolean>

View File

@@ -25,7 +25,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.core.bool.orTrue
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope
@@ -36,7 +35,6 @@ import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_preferences")
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
private val themeKey = stringPreferencesKey("theme")
@@ -48,19 +46,6 @@ class DefaultAppPreferencesStore @Inject constructor(
) : AppPreferencesStore {
private val store = context.dataStore
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
store.edit { prefs ->
prefs[richTextEditorKey] = enabled
}
}
override fun isRichTextEditorEnabledFlow(): Flow<Boolean> {
return store.data.map { prefs ->
// enabled by default
prefs[richTextEditorKey].orTrue()
}
}
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
store.edit { prefs ->
prefs[developerModeKey] = enabled

View File

@@ -21,24 +21,14 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryAppPreferencesStore(
isRichTextEditorEnabled: Boolean = false,
isDeveloperModeEnabled: Boolean = false,
customElementCallBaseUrl: String? = null,
theme: String? = null,
) : AppPreferencesStore {
private val isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
private val theme = MutableStateFlow(theme)
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
isRichTextEditorEnabled.value = enabled
}
override fun isRichTextEditorEnabledFlow(): Flow<Boolean> {
return isRichTextEditorEnabled
}
override suspend fun setDeveloperModeEnabled(enabled: Boolean) {
isDeveloperModeEnabled.value = enabled
}

View File

@@ -75,9 +75,14 @@ object TestTags {
val welcomeScreenTitle = TestTag("welcome_screen-title")
/**
* RichTextEditor.
* TextEditor.
*/
val richTextEditor = TestTag("rich_text_editor")
val textEditor = TestTag("text_editor")
/**
* EditText inside the MarkdownTextInput.
*/
val plainTextEditor = TestTag("plain_text_editor")
/**
* Message bubble.

View File

@@ -22,6 +22,9 @@ plugins {
android {
namespace = "io.element.android.libraries.textcomposer"
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
dependencies {
@@ -47,9 +50,13 @@ dependencies {
ksp(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,184 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun ComposerModeView(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
) {
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(onResetComposerMode = onResetComposerMode)
}
is MessageComposerMode.Reply -> {
ReplyToModeView(
modifier = Modifier.padding(8.dp),
senderName = composerMode.senderName,
text = composerMode.defaultContent,
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
onResetComposerMode = onResetComposerMode,
)
}
else -> Unit
}
}
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp)
) {
Icon(
imageVector = CompoundIcons.Edit(),
contentDescription = stringResource(CommonStrings.common_editing),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp),
)
Text(
stringResource(CommonStrings.common_editing),
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
)
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}
@Composable
private fun ReplyToModeView(
senderName: String,
text: String?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp)
) {
if (attachmentThumbnailInfo != null) {
AttachmentThumbnail(
info = attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(9.dp))
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
Text(
text = senderName,
modifier = Modifier
.fillMaxWidth()
.clipToBounds(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
.size(16.dp)
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}

View File

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

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.components.markdown
import android.content.Context
import androidx.appcompat.widget.AppCompatEditText
internal class MarkdownEditText(
context: Context,
) : AppCompatEditText(context) {
var onSelectionChangeListener: ((Int, Int) -> Unit)? = null
private var isModifyingText = false
fun updateEditableText(charSequence: CharSequence) {
isModifyingText = true
editableText.clear()
editableText.append(charSequence)
isModifyingText = false
}
override fun setText(text: CharSequence?, type: BufferType?) {
isModifyingText = true
super.setText(text, type)
isModifyingText = false
}
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
if (!isModifyingText) {
onSelectionChangeListener?.invoke(selStart, selEnd)
}
}
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.components.markdown
import android.graphics.Color
import android.text.Editable
import android.text.Selection
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.getSpans
import androidx.core.view.setPadding
import androidx.core.widget.addTextChangedListener
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.wysiwyg.compose.RichTextEditorStyle
import io.element.android.wysiwyg.compose.internal.applyStyleInCompose
@Suppress("ModifierMissing")
@Composable
fun MarkdownTextInput(
state: MarkdownTextEditorState,
subcomposing: Boolean,
onTyping: (Boolean) -> Unit,
onSuggestionReceived: (Suggestion?) -> Unit,
richTextEditorStyle: RichTextEditorStyle,
) {
val canUpdateState = !subcomposing
AndroidView(
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
factory = { context ->
MarkdownEditText(context).apply {
tag = TestTags.plainTextEditor.value // Needed for UI tests
setPadding(0)
setBackgroundColor(Color.TRANSPARENT)
setText(state.text.value())
if (canUpdateState) {
setSelection(state.selection.first, state.selection.last)
setOnFocusChangeListener { _, hasFocus ->
state.hasFocus = hasFocus
}
addTextChangedListener { editable ->
onTyping(!editable.isNullOrEmpty())
state.text.update(editable, false)
state.lineCount = lineCount
state.currentMentionSuggestion = editable?.checkSuggestionNeeded()
onSuggestionReceived(state.currentMentionSuggestion)
}
onSelectionChangeListener = { selStart, selEnd ->
state.selection = selStart..selEnd
state.currentMentionSuggestion = editableText.checkSuggestionNeeded()
onSuggestionReceived(state.currentMentionSuggestion)
}
state.requestFocusAction = { this.requestFocus() }
}
}
},
update = { editText ->
editText.applyStyleInCompose(richTextEditorStyle)
if (state.text.needsDisplaying()) {
editText.updateEditableText(state.text.value())
if (canUpdateState) {
state.text.update(editText.editableText, false)
}
}
if (canUpdateState) {
val newSelectionStart = state.selection.first
val newSelectionEnd = state.selection.last
val currentTextRange = 0..editText.editableText.length
val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd }
val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange }
if (didSelectionChange() && isNewSelectionValid()) {
editText.setSelection(state.selection.first, state.selection.last)
}
}
}
)
}
private fun Editable.checkSuggestionNeeded(): Suggestion? {
if (this.isEmpty()) return null
val start = Selection.getSelectionStart(this)
val end = Selection.getSelectionEnd(this)
var startOfWord = start
while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) {
startOfWord--
}
if (startOfWord !in indices) return null
val firstChar = this[startOfWord]
// If a mention span already exists we don't need suggestions
if (getSpans<MentionSpan>(startOfWord, startOfWord + 1).isNotEmpty()) return null
return if (firstChar in listOf('@', '#', '/')) {
var endOfWord = end
while (endOfWord < this.length && !this[endOfWord].isWhitespace()) {
endOfWord++
}
val text = this.subSequence(startOfWord + 1, endOfWord).toString()
val suggestionType = when (firstChar) {
'@' -> SuggestionType.Mention
'#' -> SuggestionType.Room
'/' -> SuggestionType.Command
else -> error("Unknown suggestion type. This should never happen.")
}
Suggestion(startOfWord, endOfWord, suggestionType, text)
} else {
null
}
}
@PreviewsDayNight
@Composable
internal fun MarkdownTextInputPreview() {
ElementPreview {
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = true)
MarkdownTextInput(
state = aMarkdownTextEditorState(),
subcomposing = false,
onTyping = {},
onSuggestionReceived = {},
richTextEditorStyle = style,
)
}
}
internal fun aMarkdownTextEditorState(
initialText: String = "Hello, World!",
initialFocus: Boolean = true,
) = MarkdownTextEditorState(
initialText = initialText,
initialFocus = initialFocus,
)

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.components.markdown
import android.text.SpannableString
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.element.android.libraries.core.extensions.orEmpty
@Stable
class StableCharSequence(initialText: CharSequence = "") {
private var value by mutableStateOf<SpannableString>(SpannableString(initialText))
private var needsDisplaying by mutableStateOf(false)
fun update(newText: CharSequence?, needsDisplaying: Boolean) {
value = SpannableString(newText.orEmpty())
this.needsDisplaying = needsDisplaying
}
fun value(): CharSequence = value
fun needsDisplaying(): Boolean = needsDisplaying
override fun toString(): String {
return "ImmutableCharSequence(value='$value', needsDisplaying=$needsDisplaying)"
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,13 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.messages.impl.mentions
package io.element.android.libraries.textcomposer.mentions
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.room.RoomMember
@Immutable
sealed interface MentionSuggestion {
data object Room : MentionSuggestion
data class Member(val roomMember: RoomMember) : MentionSuggestion
sealed interface ResolvedMentionSuggestion {
data object AtRoom : ResolvedMentionSuggestion
data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.model
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.text.getSpans
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
@Stable
class MarkdownTextEditorState(
initialText: String?,
initialFocus: Boolean,
) {
var text by mutableStateOf(StableCharSequence(initialText ?: ""))
var selection by mutableStateOf(0..0)
var hasFocus by mutableStateOf(initialFocus)
var requestFocusAction by mutableStateOf({})
var lineCount by mutableIntStateOf(1)
var currentMentionSuggestion by mutableStateOf<Suggestion?>(null)
fun insertMention(
mention: ResolvedMentionSuggestion,
mentionSpanProvider: MentionSpanProvider,
permalinkBuilder: PermalinkBuilder,
) {
val suggestion = currentMentionSuggestion ?: return
when (mention) {
is ResolvedMentionSuggestion.AtRoom -> {
val currentText = SpannableStringBuilder(text.value())
val replaceText = "@room"
val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "")
currentText.replace(suggestion.start, suggestion.end, ". ")
val end = suggestion.start + 1
currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
text.update(currentText, true)
selection = IntRange(end + 1, end + 1)
}
is ResolvedMentionSuggestion.Member -> {
val currentText = SpannableStringBuilder(text.value())
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return
val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link)
currentText.replace(suggestion.start, suggestion.end, ". ")
val end = suggestion.start + 1
currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
this.text.update(currentText, true)
this.selection = IntRange(end + 1, end + 1)
}
}
}
fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String {
val charSequence = text.value()
return if (charSequence is Spanned) {
val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java)
buildString {
append(charSequence.toString())
if (mentions != null && mentions.isNotEmpty()) {
for (mention in mentions.reversed()) {
val start = charSequence.getSpanStart(mention)
val end = charSequence.getSpanEnd(mention)
if (mention.type == MentionSpan.Type.USER) {
if (mention.rawValue == "@room") {
replace(start, end, "@room")
} else {
val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue
replace(start, end, "[${mention.text}]($link)")
}
}
}
}
}
} else {
charSequence.toString()
}
}
fun getMentions(): List<Mention> {
val text = SpannableString(text.value())
val mentionSpans = text.getSpans<MentionSpan>(0, text.length)
return mentionSpans.mapNotNull { mentionSpan ->
when (mentionSpan.type) {
MentionSpan.Type.USER -> {
if (mentionSpan.rawValue == "@room") {
Mention.AtRoom
} else {
Mention.User(UserId(mentionSpan.rawValue))
}
}
else -> null
}
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.wysiwyg.compose.RichTextEditorState
@Immutable
sealed interface TextEditorState {
data class Markdown(
val state: MarkdownTextEditorState,
) : TextEditorState
data class Rich(
val richTextEditorState: RichTextEditorState
) : TextEditorState
fun messageHtml(): String? = when (this) {
is Markdown -> null
is Rich -> richTextEditorState.messageHtml
}
fun messageMarkdown(permalinkBuilder: PermalinkBuilder): String = when (this) {
is Markdown -> state.getMessageMarkdown(permalinkBuilder)
is Rich -> richTextEditorState.messageMarkdown
}
fun hasFocus(): Boolean = when (this) {
is Markdown -> state.hasFocus
is Rich -> richTextEditorState.hasFocus
}
suspend fun reset() {
when (this) {
is Markdown -> {
state.selection = IntRange.EMPTY
state.text.update("", true)
}
is Rich -> richTextEditorState.setHtml("")
}
}
suspend fun requestFocus() {
when (this) {
is Markdown -> state.requestFocusAction()
is Rich -> richTextEditorState.requestFocus()
}
}
val lineCount: Int get() = when (this) {
is Markdown -> state.lineCount
is Rich -> richTextEditorState.lineCount
}
}

View File

@@ -0,0 +1,201 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.impl.components.markdown
import android.widget.EditText
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.core.text.getSpans
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EventsRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MarkdownTextInputTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `when user types onTyping is triggered with value 'true'`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onTyping = EnsureCalledOnceWithParam(expectedParam = true, result = Unit)
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
rule.activityRule.scenario.onActivity {
it.findEditor().setText("Test")
}
rule.awaitIdle()
onTyping.assertSuccess()
}
@Test
fun `when user removes text onTyping is triggered with value 'false'`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onTyping = EventsRecorder<Boolean>()
rule.setMarkdownTextInput(state = state, onTyping = onTyping)
rule.activityRule.scenario.onActivity {
val editText = it.findEditor()
editText.setText("Test")
editText.setText("")
editText.setText(null)
}
rule.awaitIdle()
onTyping.assertList(listOf(true, false, false))
}
@Test
fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onSuggestionReceived = EventsRecorder<Suggestion?>()
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
rule.activityRule.scenario.onActivity {
it.findEditor().setText("Test")
}
rule.awaitIdle()
onSuggestionReceived.assertSingle(null)
}
@Test
fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest {
val state = aMarkdownTextEditorState(initialFocus = true)
val onSuggestionReceived = EventsRecorder<Suggestion?>()
rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived)
rule.activityRule.scenario.onActivity {
it.findEditor().setText("@")
it.findEditor().setText("#")
it.findEditor().setText("/")
}
rule.awaitIdle()
onSuggestionReceived.assertList(
listOf(
// User mention suggestion
Suggestion(0, 1, SuggestionType.Mention, ""),
// Room suggestion
Suggestion(0, 1, SuggestionType.Room, ""),
// Slash command suggestion
Suggestion(0, 1, SuggestionType.Command, ""),
)
)
}
@Test
fun `when the selection changes in the UI the state is updated`() = runTest {
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
rule.setMarkdownTextInput(state = state)
rule.activityRule.scenario.onActivity {
val editor = it.findEditor()
editor.setSelection(2)
}
rule.awaitIdle()
// Selection is updated
assertThat(state.selection).isEqualTo(2..2)
}
@Test
fun `when the selection state changes in the view is updated`() = runTest {
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true)
rule.setMarkdownTextInput(state = state)
var editor: EditText? = null
rule.activityRule.scenario.onActivity {
editor = it.findEditor()
state.selection = 2..2
}
rule.awaitIdle()
// Selection state is updated
assertThat(editor?.selectionStart).isEqualTo(2)
assertThat(editor?.selectionEnd).isEqualTo(2)
}
@Test
fun `when the view focus changes the state is updated`() = runTest {
val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false)
rule.setMarkdownTextInput(state = state)
rule.activityRule.scenario.onActivity {
val editor = it.findEditor()
editor.requestFocus()
}
// Focus state is updated
assertThat(state.hasFocus).isTrue()
}
@Test
fun `inserting a mention replaces the existing text with a span`() = runTest {
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) })
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$A_SESSION_ID") })
val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true)
state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "")
rule.setMarkdownTextInput(state = state)
var editor: EditText? = null
rule.activityRule.scenario.onActivity {
editor = it.findEditor()
state.insertMention(
ResolvedMentionSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser),
permalinkBuilder,
)
}
rule.awaitIdle()
// Text is replaced with a placeholder
assertThat(editor?.editableText.toString()).isEqualTo(". ")
// The placeholder contains a MentionSpan
val mentionSpans = editor?.editableText?.getSpans<MentionSpan>(0, 2).orEmpty()
assertThat(mentionSpans).isNotEmpty()
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMarkdownTextInput(
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
subcomposing: Boolean = false,
onTyping: (Boolean) -> Unit = {},
onSuggestionReceived: (Suggestion?) -> Unit = {},
) {
rule.setContent {
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus)
MarkdownTextInput(
state = state,
subcomposing = subcomposing,
onTyping = onTyping,
onSuggestionReceived = onSuggestionReceived,
richTextEditorStyle = style,
)
}
}
private fun ComponentActivity.findEditor(): EditText {
return window.decorView.findViewWithTag(TestTags.plainTextEditor.value)
}
}

View File

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

View File

@@ -0,0 +1,171 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.impl.model
import android.net.Uri
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.SuggestionType
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MarkdownTextEditorStateTest {
@Test
fun `insertMention - with no currentMentionSuggestion does nothing`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val permalinkBuilder = FakePermalinkBuilder()
val mentionSpanProvider = aMentionSpanProvider()
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `insertMention - with member but failed PermalinkBuilder result`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(result = { Result.failure(IllegalStateException("Failed")) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isEmpty()
}
@Test
fun `insertMention - with member`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val member = aRoomMember()
val mention = ResolvedMentionSuggestion.Member(member)
val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) })
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/${member.userId}") })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId)
}
@Test
fun `insertMention - with @room`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply {
currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "")
}
val mention = ResolvedMentionSuggestion.AtRoom
val permalinkBuilder = FakePermalinkBuilder()
val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) })
val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser)
state.insertMention(mention, mentionSpanProvider, permalinkBuilder)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java)
}
@Test
fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() {
val text = "No mentions here"
val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
val markdown = state.getMessageMarkdown(FakePermalinkBuilder())
assertThat(markdown).isEqualTo(text)
}
@Test
fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() {
val text = "No mentions here"
val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$it") })
val state = MarkdownTextEditorState(initialText = text, initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo(
"Hello [@Alice](https://matrix.to/#/@alice:matrix.org) and everyone in @room"
)
}
@Test
fun `getMentions - when there are no MentionSpans returns empty list of mentions`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
assertThat(state.getMentions()).isEmpty()
}
@Test
fun `getMentions - when there are MentionSpans returns a list of mentions`() {
val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true)
state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false)
val mentions = state.getMentions()
assertThat(mentions).isNotEmpty()
assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org")
assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java)
}
private fun aMentionSpanProvider(
currentSessionId: SessionId = A_SESSION_ID,
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
): MentionSpanProvider {
return MentionSpanProvider(currentSessionId, permalinkParser)
}
private fun aMarkdownTextWithMentions(): CharSequence {
val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER, 0, 0, 0, 0)
val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.USER, 0, 0, 0, 0)
return buildSpannedString {
append("Hello ")
inSpans(userMentionSpan) {
append("@")
}
append(" and everyone in ")
inSpans(atRoomMentionSpan) {
append("@")
}
}
}
}

View File

@@ -27,6 +27,15 @@ pluginManagement {
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
// Snapshot versions
maven {
url = URI("https://s01.oss.sonatype.org/content/repositories/snapshots")
content {
includeModule("org.matrix.rustcomponents", "sdk-android")
includeModule("io.element.android", "wysiwyg")
includeModule("io.element.android", "wysiwyg-compose")
}
}
google()
mavenCentral()
maven { url = URI("https://oss.sonatype.org/content/repositories/snapshots/") }

View File

@@ -78,6 +78,7 @@ class KonsistPreviewTest {
"IconTitleSubtitleMoleculeWithResIconPreview",
"IconsCompoundPreview",
"IconsOtherPreview",
"MarkdownTextComposerEditPreview",
"MentionSpanPreview",
"MessageComposerViewVoicePreview",
"MessagesReactionButtonAddPreview",