From 489fde73b9575604d66f653b5856694c3dcbb8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 May 2024 23:30:55 +0200 Subject: [PATCH 01/20] Add plain text editor based on markdown input --- .../features/messages/impl/MessagesView.kt | 2 +- .../MessageComposerPresenter.kt | 114 ++++++++++--- .../messagecomposer/MessageComposerState.kt | 6 +- .../MessageComposerStateProvider.kt | 3 +- .../messagecomposer/MessageComposerView.kt | 4 +- .../messages/impl/MessagesPresenterTest.kt | 6 +- .../MessageComposerPresenterTest.kt | 80 ++++----- .../libraries/textcomposer/TextComposer.kt | 125 +++++++++----- .../components/MarkdownTextInput.kt | 156 ++++++++++++++++++ .../textcomposer/mentions/MentionSpan.kt | 16 +- .../mentions/MentionSpanProvider.kt | 18 ++ .../textcomposer/model/TextEditorState.kt | 74 +++++++++ 12 files changed, 488 insertions(+), 116 deletions(-) create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 597fd4b8c9d..1fe116b26ea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -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() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 30bdadcbe53..5b3eb20d418 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -19,6 +19,9 @@ package io.element.android.features.messages.impl.messagecomposer import android.Manifest import android.annotation.SuppressLint import android.net.Uri +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -31,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.core.text.getSpans import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import im.vector.app.features.analytics.plan.Composer @@ -39,6 +43,7 @@ import io.element.android.features.messages.impl.attachments.preview.error.sendA 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.AppPreferencesStore import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -59,9 +64,14 @@ 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.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +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 @@ -102,6 +112,7 @@ class MessageComposerPresenter @Inject constructor( private val permalinkBuilder: PermalinkBuilder, permissionsPresenterFactory: PermissionsPresenter.Factory, private val timelineController: TimelineController, + private val appPreferencesStore: AppPreferencesStore, ) : Presenter { private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private var pendingEvent: MessageComposerEvents? = null @@ -113,6 +124,10 @@ class MessageComposerPresenter @Inject constructor( @Composable override fun present(): MessageComposerState { val localCoroutineScope = rememberCoroutineScope() + val isRichTextEditorEnabled by appPreferencesStore + .isRichTextEditorEnabledFlow() + .collectAsState(initial = false) + var markdownTextEditorState by remember { mutableStateOf(MarkdownTextEditorState()) } var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -229,13 +244,21 @@ class MessageComposerPresenter @Inject constructor( } } + val textEditorState = if (isRichTextEditorEnabled) { + TextEditorState.Rich(richTextEditorState) + } else { + TextEditorState.Markdown(markdownTextEditorState) + } + + val mentionSpanProvider = 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 @@ -243,7 +266,7 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( message = event.message, updateComposerMode = { messageComposerContext.composerMode = it }, - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, ) is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment( attachment = Attachment.Media( @@ -335,14 +358,44 @@ class MessageComposerPresenter @Inject constructor( } is MessageComposerEvents.InsertMention -> { localCoroutineScope.launch { - when (val mention = event.mention) { - is MentionSuggestion.Room -> { - richTextEditorState.insertAtRoomMentionAtSuggestion() + if (isRichTextEditorEnabled) { + when (val mention = event.mention) { + is MentionSuggestion.Room -> { + richTextEditorState.insertAtRoomMentionAtSuggestion() + } + 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) + } } - 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) { + when (val mention = event.mention) { + is MentionSuggestion.Room -> { + val suggestion = markdownTextEditorState.currentMentionSuggestion!! + val currentText = SpannableStringBuilder(markdownTextEditorState.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) + markdownTextEditorState.text.update(currentText, true) + markdownTextEditorState.selection = IntRange(end + 1, end + 1) + suggestionSearchTrigger.value = null + } + is MentionSuggestion.Member -> { + val suggestion = markdownTextEditorState.currentMentionSuggestion!! + val currentText = SpannableStringBuilder(markdownTextEditorState.text.value()) + val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch + 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) + markdownTextEditorState.text.update(currentText, true) + markdownTextEditorState.selection = IntRange(end + 1, end + 1) + suggestionSearchTrigger.value = null + } } } } @@ -351,7 +404,7 @@ class MessageComposerPresenter @Inject constructor( } return MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, permalinkParser = permalinkParser, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, @@ -369,21 +422,42 @@ 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))) + // TODO: handle mentions in markdown editor + 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() + } + is TextEditorState.Markdown -> { + val text = SpannableString(textEditorState.state.text.value()) + val mentionSpans = text.getSpans(0, text.length) + mentionSpans.mapNotNull { mentionSpan -> + when (mentionSpan.type) { + MentionSpan.Type.USER -> { + if (mentionSpan.rawValue == "@room") { + Mention.AtRoom + } else { + Mention.User(UserId(mentionSpan.rawValue)) + } + } + else -> null + } } } - }.orEmpty() + } // 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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 194ce1914cd..adf5a72c921 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -23,12 +23,12 @@ 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.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, @@ -41,7 +41,7 @@ data class MessageComposerState( val currentUserId: UserId, val eventSink: (MessageComposerEvents) -> Unit, ) { - val hasFocus: Boolean = richTextEditorState.hasFocus + val hasFocus: Boolean = textEditorState.hasFocus() } @Immutable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 340f7328f33..e6e2ff15f95 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -23,6 +23,7 @@ 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.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -45,7 +46,7 @@ fun aMessageComposerState( attachmentsState: AttachmentsState = AttachmentsState.None, memberSuggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = TextEditorState.Rich(richTextEditorState), permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData = TODO() }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index e68cf298447..37252020a2d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -85,7 +85,7 @@ internal fun MessageComposerView( val coroutineScope = rememberCoroutineScope() fun onRequestFocus() { coroutineScope.launch { - state.richTextEditorState.requestFocus() + state.textEditorState.requestFocus() } } @@ -107,7 +107,7 @@ internal fun MessageComposerView( TextComposer( modifier = modifier, - state = state.richTextEditorState, + state = state.textEditorState, voiceMessageState = voiceMessageState.voiceMessageState, permalinkParser = state.permalinkParser, subcomposing = subcomposing, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 3adc7059b0a..0682675a47b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -511,7 +511,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() @@ -534,7 +534,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() } @@ -549,7 +549,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() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 7274027059f..9d7c55d4d8a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -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,7 +232,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) } } @@ -242,15 +242,15 @@ 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.richTextEditorState.setHtml(A_MESSAGE) + initialState.textEditorState.setHtml(A_MESSAGE) val withMessageState = awaitItem() - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml).isEqualTo(A_MESSAGE) withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml).isEqualTo("") waitForPredicate { analyticsService.capturedEvents.size == 1 } assertThat(analyticsService.capturedEvents).containsExactly( Composer( @@ -278,23 +278,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) + assertThat(withEditedMessageState.textEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml).isEqualTo("") advanceUntilIdle() @@ -328,23 +328,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) + assertThat(withEditedMessageState.textEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml).isEqualTo("") advanceUntilIdle() @@ -380,17 +380,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) + assertThat(state.textEditorState.messageHtml).isEqualTo("") + state.textEditorState.setHtml(A_REPLY) + assertThat(state.textEditorState.messageHtml).isEqualTo(A_REPLY) state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage())) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml).isEqualTo("") advanceUntilIdle() @@ -862,10 +862,10 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml("Hey @bo") + initialState.textEditorState.setHtml("Hey @bo") initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) - assertThat(initialState.richTextEditorState.messageHtml) + assertThat(initialState.textEditorState.messageHtml) .isEqualTo("Hey ${A_USER_ID_2.value}") } } @@ -892,13 +892,13 @@ class MessageComposerPresenterTest { // Check intentional mentions on message sent val mentionUser1 = listOf(A_USER_ID.value) - initialState.richTextEditorState.mentionsState = MentionsState( + initialState.textEditorState.mentionsState = MentionsState( userIds = mentionUser1, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.richTextEditorState.setHtml(A_MESSAGE) + initialState.textEditorState.setHtml(A_MESSAGE) initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) advanceUntilIdle() @@ -908,7 +908,7 @@ 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.mentionsState = MentionsState( userIds = mentionUser2, roomIds = emptyList(), roomAliases = emptyList(), @@ -926,7 +926,7 @@ class MessageComposerPresenterTest { skipItems(1) initialState.eventSink(MessageComposerEvents.SetMode(anEditMode())) val mentionUser3 = listOf(A_USER_ID_3.value) - awaitItem().richTextEditorState.mentionsState = MentionsState( + awaitItem().textEditorState.mentionsState = MentionsState( userIds = mentionUser3, roomIds = emptyList(), roomAliases = emptyList(), @@ -949,7 +949,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"))) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index e31183bd143..e1a9d56746a 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -17,6 +17,9 @@ package io.element.android.libraries.textcomposer import android.net.Uri +import android.text.Spannable +import android.text.Spanned +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -39,7 +42,6 @@ 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 @@ -73,6 +75,7 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.textcomposer.components.ComposerOptionsButton import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton +import io.element.android.libraries.textcomposer.components.MarkdownTextInput import io.element.android.libraries.textcomposer.components.SendButton import io.element.android.libraries.textcomposer.components.TextFormatting import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton @@ -80,10 +83,12 @@ 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.textInputRoundedCornerShape +import io.element.android.libraries.textcomposer.mentions.MentionSpan 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,7 +103,7 @@ import kotlin.time.Duration.Companion.seconds @Composable fun TextComposer( - state: RichTextEditorState, + state: TextEditorState, voiceMessageState: VoiceMessageState, permalinkParser: PermalinkParser, composerMode: MessageComposerMode, @@ -122,9 +127,15 @@ 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)) + val html = if (state is TextEditorState.Rich && enableTextFormatting) { + state.richTextEditorState.messageHtml + } else null + onSendMessage(Message(html = html, markdown = charSequenceToMarkdown(markdown))) } val onPlayVoiceMessageClicked = { @@ -153,32 +164,47 @@ 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 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 = 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, + ) + } + } + } + is TextEditorState.Markdown -> { + @Composable { + MarkdownTextInput( + state = state.state, + subcomposing = subcomposing, + onTyping = onTyping, + onSuggestionReceived = onSuggestionReceived, + modifier = Modifier.fillMaxSize() + ) + } } } - val canSendMessage by remember { derivedStateOf { state.messageMarkdown.isNotBlank() } } + val canSendMessage = markdown.isNotBlank() val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, @@ -205,7 +231,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 +245,7 @@ fun TextComposer( false -> sendVoiceButton } } - else -> - sendButton + else -> sendButton } val voiceRecording = @Composable { @@ -251,7 +278,7 @@ fun TextComposer( } } - if (showTextFormatting) { + if (showTextFormatting && textFormattingOptions != null) { TextFormattingLayout( modifier = layoutModifier, textInput = textInput, @@ -282,14 +309,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) + } } } } @@ -599,6 +628,23 @@ private fun ReplyToModeView( } } +private fun charSequenceToMarkdown(charSequence: CharSequence): String { + return if (charSequence is Spanned) { + val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java) + buildString { + append(charSequence) + for (mention in mentions.reversed()) { + val start = charSequence.getSpanStart(mention) + val end = charSequence.getSpanEnd(mention) + // TODO: Use permalinkBuilder instead + replace(start, end, "${mention.text}") + } + } + } else { + charSequence.toString() + } +} + @PreviewsDayNight @Composable internal fun TextComposerSimplePreview() = ElementPreview { @@ -911,7 +957,8 @@ private fun ATextComposer( showTextFormatting: Boolean = false, ) { TextComposer( - state = richTextEditorState, + // TODO: fix this + state = TextEditorState.Rich(richTextEditorState), showTextFormatting = showTextFormatting, voiceMessageState = voiceMessageState, permalinkParser = object : PermalinkParser { diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt new file mode 100644 index 00000000000..a1d68ae86f2 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt @@ -0,0 +1,156 @@ +/* + * 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 + +import android.content.Context +import android.text.Editable +import android.text.Selection +import android.text.Spannable +import android.text.SpannableString +import android.widget.EditText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.getSpans +import androidx.core.widget.addTextChangedListener +import io.element.android.libraries.core.extensions.orEmpty +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +@Composable +fun MarkdownTextInput( + state: MarkdownTextEditorState, + subcomposing: Boolean, + onTyping: (Boolean) -> Unit, + onSuggestionReceived: (Suggestion?) -> Unit, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + AndroidView( + modifier = modifier, + factory = { context -> + MarkdownEditText(context).apply { + setText(state.text.value()) + if (!subcomposing) { + setSelection(state.selection.first, state.selection.last) + setOnFocusChangeListener { _, hasFocus -> + state.hasFocus = hasFocus + } + addTextChangedListener { editable -> + coroutineScope.launch(Dispatchers.Main) { + 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.onRequestFocus = { this.requestFocus() } + } + } + }, + update = { editText -> + if (state.text.needsDisplaying()) { + editText.editableText.clear() + editText.editableText.append(state.text.value()) + } + if ((editText.selectionStart != state.selection.first && state.selection.first in 0..editText.editableText.length || + editText.selectionEnd != state.selection.last) && state.selection.last in 0..editText.editableText.length) { + println("Changing selection to ${state.selection}") + 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 (getSpans(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 -> return null + } + Suggestion(startOfWord, endOfWord, suggestionType, text) + } else { + null + } +} + +class MarkdownEditText( + context: Context, +) : EditText(context) { + var onSelectionChangeListener: ((Int, Int) -> Unit)? = null + + private var 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) + } + } +} + +class ImmutableCharSequence(initialText: CharSequence = "") { + private var value by mutableStateOf(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 +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt index 9788f1f6c34..50314aa9d38 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -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, @@ -46,14 +48,14 @@ class MentionSpan( } 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 @@ -69,24 +71,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 diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt index 7d8bfd34cec..efdaf685215 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -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, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt new file mode 100644 index 00000000000..a6e93f9a9bd --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -0,0 +1,74 @@ +/* + * 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.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.element.android.libraries.textcomposer.components.ImmutableCharSequence +import io.element.android.wysiwyg.compose.RichTextEditorState + +sealed interface TextEditorState { + data class Markdown( + val state: MarkdownTextEditorState, + ) : TextEditorState + + data class Rich( + val richTextEditorState: RichTextEditorState + ) : TextEditorState + + 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 = ImmutableCharSequence() + } + is Rich -> richTextEditorState.setHtml("") + } + } + + suspend fun requestFocus() { + when (this) { + is Markdown -> state.onRequestFocus() + is Rich -> richTextEditorState.requestFocus() + } + } + + val lineCount: Int get() = when (this) { + is Markdown -> state.lineCount + is Rich -> richTextEditorState.lineCount + } +} + +@Stable +class MarkdownTextEditorState( + initialText: String? = null, +) { + var text by mutableStateOf(ImmutableCharSequence(initialText ?: "")) + var selection by mutableStateOf(0..0) + var hasFocus by mutableStateOf(false) + var onRequestFocus by mutableStateOf({}) + var lineCount by mutableIntStateOf(1) + var currentMentionSuggestion by mutableStateOf(null) +} From be4e0fbfcd2662f7b23218b3ffc17d2debf61bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 6 May 2024 23:39:40 +0200 Subject: [PATCH 02/20] Improve layout, fix lots of minor issues --- .../MessageComposerPresenter.kt | 6 +- .../libraries/textcomposer/TextComposer.kt | 10 +- .../components/MarkdownTextInput.kt | 148 +++++++++++++----- .../textcomposer/mentions/MentionSpan.kt | 6 +- .../textcomposer/model/TextEditorState.kt | 2 +- 5 files changed, 121 insertions(+), 51 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 5b3eb20d418..f5d6ff634ea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -65,7 +65,6 @@ 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.MentionSpan -import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Message @@ -73,7 +72,6 @@ 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 @@ -376,7 +374,7 @@ class MessageComposerPresenter @Inject constructor( val currentText = SpannableStringBuilder(markdownTextEditorState.text.value()) val replaceText = "@room" val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "") - currentText.replace(suggestion.start, suggestion.end, " ") + currentText.replace(suggestion.start, suggestion.end, ". ") val end = suggestion.start + 1 currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) markdownTextEditorState.text.update(currentText, true) @@ -389,7 +387,7 @@ class MessageComposerPresenter @Inject constructor( val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) - currentText.replace(suggestion.start, suggestion.end, " ") + currentText.replace(suggestion.start, suggestion.end, ". ") val end = suggestion.start + 1 currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) markdownTextEditorState.text.update(currentText, true) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index e1a9d56746a..f8a83916064 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -198,7 +198,9 @@ fun TextComposer( subcomposing = subcomposing, onTyping = onTyping, onSuggestionReceived = onSuggestionReceived, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, ) } } @@ -495,7 +497,7 @@ private fun TextInput( } @Composable -private fun ComposerModeView( +internal fun ComposerModeView( composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, ) { @@ -637,7 +639,9 @@ private fun charSequenceToMarkdown(charSequence: CharSequence): String { val start = charSequence.getSpanStart(mention) val end = charSequence.getSpanEnd(mention) // TODO: Use permalinkBuilder instead - replace(start, end, "${mention.text}") + if (mention.type == MentionSpan.Type.USER) { + replace(start, end, "[${mention.text}](https://matrix.to/#/${mention.rawValue})") + } } } } else { diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt index a1d68ae86f2..c1f2055a0c7 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt @@ -17,28 +17,49 @@ package io.element.android.libraries.textcomposer.components import android.content.Context +import android.graphics.Color import android.text.Editable import android.text.Selection -import android.text.Spannable import android.text.SpannableString import android.widget.EditText +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +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.compound.theme.ElementTheme import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.textcomposer.ComposerModeView +import io.element.android.libraries.textcomposer.R import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +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.ui.strings.CommonStrings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean @Composable fun MarkdownTextInput( @@ -46,50 +67,101 @@ fun MarkdownTextInput( subcomposing: Boolean, onTyping: (Boolean) -> Unit, onSuggestionReceived: (Suggestion?) -> Unit, + composerMode: MessageComposerMode, + onResetComposerMode: () -> Unit, modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() - AndroidView( - modifier = modifier, - factory = { context -> - MarkdownEditText(context).apply { - setText(state.text.value()) - if (!subcomposing) { - setSelection(state.selection.first, state.selection.last) - setOnFocusChangeListener { _, hasFocus -> - state.hasFocus = hasFocus - } - addTextChangedListener { editable -> - coroutineScope.launch(Dispatchers.Main) { - onTyping(!editable.isNullOrEmpty()) - state.text.update(editable, false) - state.lineCount = lineCount - - state.currentMentionSuggestion = editable?.checkSuggestionNeeded() - onSuggestionReceived(state.currentMentionSuggestion) + + val bgColor = ElementTheme.colors.bgSubtleSecondary + val borderColor = ElementTheme.colors.borderDisabled + val roundedCorners = textInputRoundedCornerShape(composerMode = composerMode) + + LaunchedEffect(composerMode) { + if (composerMode is MessageComposerMode.Edit) { + state.text.update(composerMode.defaultContent, true) + } + } + + Column( + modifier = Modifier + .clip(roundedCorners) + .border(0.5.dp, borderColor, roundedCorners) + .background(color = bgColor) + .requiredHeightIn(min = 42.dp) + .fillMaxSize(), + ) { + if (composerMode is MessageComposerMode.Special) { + ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode) + } + val defaultTypography = ElementTheme.typography.fontBodyLgRegular + Box( + modifier = Modifier + .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) + .testTag(TestTags.richTextEditor), + contentAlignment = Alignment.CenterStart, + ) { + // Placeholder + if (state.text.value().isEmpty()) { + Text( + text = if (composerMode.inThread) { + stringResource(id = CommonStrings.action_reply_in_thread) + } else { + stringResource(id = R.string.rich_text_editor_composer_placeholder) + }, + style = defaultTypography.copy( + color = ElementTheme.colors.textSecondary, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + AndroidView( + modifier = modifier, + factory = { context -> + MarkdownEditText(context).apply { + setPadding(0) + setBackgroundColor(Color.TRANSPARENT) + setText(state.text.value()) + if (!subcomposing) { + setSelection(state.selection.first, state.selection.last) + setOnFocusChangeListener { _, hasFocus -> + state.hasFocus = hasFocus + } + addTextChangedListener { editable -> + coroutineScope.launch(Dispatchers.Main) { + 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.onRequestFocus = { this.requestFocus() } } } - onSelectionChangeListener = { selStart, selEnd -> - state.selection = selStart..selEnd - state.currentMentionSuggestion = editableText.checkSuggestionNeeded() - onSuggestionReceived(state.currentMentionSuggestion) + }, + update = { editText -> + if (state.text.needsDisplaying()) { + editText.editableText.clear() + editText.editableText.append(state.text.value()) + } + if ((editText.selectionStart != state.selection.first && state.selection.first in 0..editText.editableText.length || + editText.selectionEnd != state.selection.last) && state.selection.last in 0..editText.editableText.length) { + println("Changing selection to ${state.selection}") + editText.setSelection(state.selection.first, state.selection.last) } - state.onRequestFocus = { this.requestFocus() } } - } - }, - update = { editText -> - if (state.text.needsDisplaying()) { - editText.editableText.clear() - editText.editableText.append(state.text.value()) - } - if ((editText.selectionStart != state.selection.first && state.selection.first in 0..editText.editableText.length || - editText.selectionEnd != state.selection.last) && state.selection.last in 0..editText.editableText.length) { - println("Changing selection to ${state.selection}") - editText.setSelection(state.selection.first, state.selection.last) - } + ) } - ) + } } private fun Editable.checkSuggestionNeeded(): Suggestion? { diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt index 50314aa9d38..fe1c0c21675 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -41,7 +41,6 @@ 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 @@ -59,11 +58,8 @@ class MentionSpan( // 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 diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt index a6e93f9a9bd..7cd26eded62 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -42,7 +42,7 @@ sealed interface TextEditorState { when (this) { is Markdown -> { state.selection = IntRange.EMPTY - state.text = ImmutableCharSequence() + state.text.update("", true) } is Rich -> richTextEditorState.setHtml("") } From d22c267ac86fdda4580bb9e7272b5d4e738d4288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 13 May 2024 14:20:49 +0200 Subject: [PATCH 03/20] Fix switching between plain text and formatted editors --- .../mentions/MentionSuggestionsPickerView.kt | 31 ++-- .../mentions/MentionSuggestionsProcessor.kt | 9 +- .../messagecomposer/MessageComposerEvents.kt | 6 +- .../MessageComposerPresenter.kt | 132 +++++++++--------- .../messagecomposer/MessageComposerState.kt | 4 +- .../MessageComposerStateProvider.kt | 4 +- .../messagecomposer/MessageComposerView.kt | 4 +- .../messages/impl/MessagesPresenterTest.kt | 5 +- .../MessageComposerPresenterTest.kt | 110 ++++++++------- gradle/libs.versions.toml | 3 +- .../libraries/textcomposer/TextComposer.kt | 33 +---- .../components/MarkdownTextInput.kt | 7 +- .../mentions/ResolvedMentionSuggestion.kt | 10 +- .../textcomposer/model/TextEditorState.kt | 94 ++++++++++++- settings.gradle.kts | 2 + 15 files changed, 267 insertions(+), 187 deletions(-) rename features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt => libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt (71%) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt index cf48ea1478b..a1165b362c1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt @@ -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, - onSuggestionSelected: (MentionSuggestion) -> Unit, + memberSuggestions: ImmutableList, + 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.Room -> "@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.Room -> 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.Room -> 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.Room -> "@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.Room, + ResolvedMentionSuggestion.Member(roomMember), + ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), ), onSuggestionSelected = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt index 696f0fe93ea..232f4b8dc63 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt @@ -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 { + ): List { val members = roomMembersState.roomMembers() return when { members.isNullOrEmpty() || suggestion == null -> { @@ -78,7 +79,7 @@ object MentionSuggestionsProcessor { roomMembers: List?, currentUserId: UserId, canSendRoomMention: Boolean, - ): List { + ): List { 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.Room) + matchingMembers } else { matchingMembers } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 9ddf1f7aaeb..18081236f82 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -18,7 +18,7 @@ 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.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion @@ -26,7 +26,7 @@ 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 +45,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 } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index f5d6ff634ea..66c382195c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -19,9 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer import android.Manifest import android.annotation.SuppressLint import android.net.Uri -import android.text.Spannable -import android.text.SpannableString -import android.text.SpannableStringBuilder +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -34,16 +32,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.core.text.getSpans import androidx.media3.common.MimeTypes 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.AppPreferencesStore import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -64,7 +59,7 @@ 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.MentionSpan +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 @@ -78,6 +73,7 @@ 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 @@ -110,22 +106,25 @@ class MessageComposerPresenter @Inject constructor( private val permalinkBuilder: PermalinkBuilder, permissionsPresenterFactory: PermissionsPresenter.Factory, private val timelineController: TimelineController, - private val appPreferencesStore: AppPreferencesStore, ) : Presenter { private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private var pendingEvent: MessageComposerEvents? = null private val suggestionSearchTrigger = MutableStateFlow(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() - val isRichTextEditorEnabled by appPreferencesStore - .isRichTextEditorEnabledFlow() - .collectAsState(initial = false) - var markdownTextEditorState by remember { mutableStateOf(MarkdownTextEditorState()) } + val markdownTextEditorState = remember { MarkdownTextEditorState() } var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -166,13 +165,13 @@ class MessageComposerPresenter @Inject constructor( val ongoingSendAttachmentJob = remember { mutableStateOf(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 -> + // No need to handle this for plain text editor richTextEditorState.setHtml(modeValue.defaultContent) else -> Unit } @@ -201,7 +200,7 @@ class MessageComposerPresenter @Inject constructor( } } - val memberSuggestions = remember { mutableStateListOf() } + val memberSuggestions = remember { mutableStateListOf() } LaunchedEffect(isMentionsEnabled) { if (!isMentionsEnabled) return@LaunchedEffect val currentUserId = currentSessionIdHolder.current @@ -242,13 +241,37 @@ class MessageComposerPresenter @Inject constructor( } } - val textEditorState = if (isRichTextEditorEnabled) { + val textEditorState = if (showTextFormatting) { TextEditorState.Rich(richTextEditorState) } else { TextEditorState.Markdown(markdownTextEditorState) } - val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = room.sessionId, permalinkParser = permalinkParser) + LaunchedEffect(showTextFormatting) { + if (showTextFormatting) { + val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + if (markdown.isNotEmpty()) { + richTextEditorState.setMarkdown(markdown) + richTextEditorState.requestFocus() + } + } else { + val markdown = richTextEditorState.messageMarkdown + if (markdown.isNotEmpty()) { + markdownTextEditorState.text.update(markdown, true) + delay(50) + markdownTextEditorState.requestFocusAction() + } + } + } + + val mentionSpanProvider = if (isTesting) { + null + } else { + rememberMentionSpanProvider( + currentUserId = room.sessionId, + permalinkParser = permalinkParser, + ) + } fun handleEvents(event: MessageComposerEvents) { when (event) { @@ -261,11 +284,21 @@ class MessageComposerPresenter @Inject constructor( } messageComposerContext.composerMode = MessageComposerMode.Normal } - is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( - message = event.message, - updateComposerMode = { messageComposerContext.composerMode = it }, - textEditorState = textEditorState, - ) + 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( @@ -356,45 +389,26 @@ class MessageComposerPresenter @Inject constructor( } is MessageComposerEvents.InsertMention -> { localCoroutineScope.launch { - if (isRichTextEditorEnabled) { + if (showTextFormatting) { when (val mention = event.mention) { - is MentionSuggestion.Room -> { + is ResolvedMentionSuggestion.Room -> { richTextEditorState.insertAtRoomMentionAtSuggestion() } - is MentionSuggestion.Member -> { + 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) } } } else if (markdownTextEditorState.currentMentionSuggestion != null) { - when (val mention = event.mention) { - is MentionSuggestion.Room -> { - val suggestion = markdownTextEditorState.currentMentionSuggestion!! - val currentText = SpannableStringBuilder(markdownTextEditorState.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) - markdownTextEditorState.text.update(currentText, true) - markdownTextEditorState.selection = IntRange(end + 1, end + 1) - suggestionSearchTrigger.value = null - } - is MentionSuggestion.Member -> { - val suggestion = markdownTextEditorState.currentMentionSuggestion!! - val currentText = SpannableStringBuilder(markdownTextEditorState.text.value()) - val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch - 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) - markdownTextEditorState.text.update(currentText, true) - markdownTextEditorState.selection = IntRange(end + 1, end + 1) - suggestionSearchTrigger.value = null - } + mentionSpanProvider?.let { + markdownTextEditorState.insertMention( + mention = event.mention, + mentionSpanProvider = it, + permalinkBuilder = permalinkBuilder, + ) } + suggestionSearchTrigger.value = null } } } @@ -423,7 +437,6 @@ class MessageComposerPresenter @Inject constructor( textEditorState: TextEditorState, ) = launch { val capturedMode = messageComposerContext.composerMode - // TODO: handle mentions in markdown editor val mentions = when (textEditorState) { is TextEditorState.Rich -> { textEditorState.richTextEditorState.mentionsState?.let { state -> @@ -437,22 +450,7 @@ class MessageComposerPresenter @Inject constructor( } }.orEmpty() } - is TextEditorState.Markdown -> { - val text = SpannableString(textEditorState.state.text.value()) - val mentionSpans = text.getSpans(0, text.length) - mentionSpans.mapNotNull { mentionSpan -> - when (mentionSpan.type) { - MentionSpan.Type.USER -> { - if (mentionSpan.rawValue == "@room") { - Mention.AtRoom - } else { - Mention.User(UserId(mentionSpan.rawValue)) - } - } - else -> null - } - } - } + is TextEditorState.Markdown -> textEditorState.state.getMentions() } // Reset composer right away textEditorState.reset() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index adf5a72c921..bbcb8aad438 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -19,7 +19,7 @@ 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.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.model.MessageComposerMode @@ -37,7 +37,7 @@ data class MessageComposerState( val canShareLocation: Boolean, val canCreatePoll: Boolean, val attachmentsState: AttachmentsState, - val memberSuggestions: ImmutableList, + val memberSuggestions: ImmutableList, val currentUserId: UserId, val eventSink: (MessageComposerEvents) -> Unit, ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index e6e2ff15f95..cfb2b8a3305 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -17,7 +17,7 @@ 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.textcomposer.mentions.ResolvedMentionSuggestion 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 @@ -44,7 +44,7 @@ fun aMessageComposerState( canShareLocation: Boolean = true, canCreatePoll: Boolean = true, attachmentsState: AttachmentsState = AttachmentsState.None, - memberSuggestions: ImmutableList = persistentListOf(), + memberSuggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( textEditorState = TextEditorState.Rich(richTextEditorState), permalinkParser = object : PermalinkParser { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 37252020a2d..631b2338280 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -49,8 +49,8 @@ internal fun MessageComposerView( 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) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 0682675a47b..28d089799e7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -773,7 +773,10 @@ class MessagesPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), timelineController = TimelineController(matrixRoom), - ) + ).apply { + showTextFormatting = true + isTesting = true + } val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( this, FakeVoiceRecorder(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 9d7c55d4d8a..032e1993081 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -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,12 @@ 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.mentions.ResolvedMentionSuggestion 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.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 +128,7 @@ class MessageComposerPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.isFullScreen).isFalse() - assertThat(initialState.textEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() @@ -159,9 +160,9 @@ class MessageComposerPresenterTest { }.test { val initialState = awaitFirstItem() initialState.textEditorState.setHtml(A_MESSAGE) - assertThat(initialState.textEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) initialState.textEditorState.setHtml("") - assertThat(initialState.textEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") } } @@ -170,7 +171,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.textEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { var state = awaitFirstItem() val mode = anEditMode() @@ -178,11 +179,11 @@ class MessageComposerPresenterTest { state = awaitItem() assertThat(state.mode).isEqualTo(mode) state = awaitItem() - assertThat(state.textEditorState.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.textEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") } } @@ -197,7 +198,7 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.textEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @@ -217,7 +218,7 @@ class MessageComposerPresenterTest { state = backToNormalMode(state) // The message typed while replying is not cleared - assertThat(state.textEditorState.messageHtml).isEqualTo(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) } } @@ -232,7 +233,7 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.textEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @@ -242,15 +243,15 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.textEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() initialState.textEditorState.setHtml(A_MESSAGE) val withMessageState = awaitItem() - assertThat(withMessageState.textEditorState.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.textEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") waitForPredicate { analyticsService.capturedEvents.size == 1 } assertThat(analyticsService.capturedEvents).containsExactly( Composer( @@ -278,23 +279,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.textEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.textEditorState.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.textEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.textEditorState.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.textEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -328,23 +329,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.textEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.textEditorState.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.textEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.textEditorState.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.textEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -380,17 +381,17 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - assertThat(initialState.textEditorState.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.textEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") state.textEditorState.setHtml(A_REPLY) - assertThat(state.textEditorState.messageHtml).isEqualTo(A_REPLY) - state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage())) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) + state.eventSink.invoke(MessageComposerEvents.SendMessage) val messageSentState = awaitItem() - assertThat(messageSentState.textEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -725,7 +726,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 { @@ -781,19 +782,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.Room, 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.Room) // 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 +804,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 +845,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)) } } @@ -863,9 +864,9 @@ class MessageComposerPresenterTest { }.test { val initialState = awaitFirstItem() initialState.textEditorState.setHtml("Hey @bo") - initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) + initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) - assertThat(initialState.textEditorState.messageHtml) + assertThat(initialState.textEditorState.messageHtml()) .isEqualTo("Hey ${A_USER_ID_2.value}") } } @@ -892,14 +893,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on message sent val mentionUser1 = listOf(A_USER_ID.value) - initialState.textEditorState.mentionsState = MentionsState( + (initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser1, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) initialState.textEditorState.setHtml(A_MESSAGE) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() @@ -908,14 +909,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on reply sent initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode())) val mentionUser2 = listOf(A_USER_ID_2.value) - awaitItem().textEditorState.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 +927,14 @@ class MessageComposerPresenterTest { skipItems(1) initialState.eventSink(MessageComposerEvents.SetMode(anEditMode())) val mentionUser3 = listOf(A_USER_ID_3.value) - awaitItem().textEditorState.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 +950,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.textEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri"))) @@ -1007,7 +1008,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 +1027,10 @@ class MessageComposerPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = permalinkBuilder, timelineController = TimelineController(room), - ) + ).apply { + isTesting = true + showTextFormatting = isRichTextEditorEnabled + } private suspend fun ReceiveTurbine.awaitFirstItem(): T { // Skip 2 item if Mentions feature is enabled, else 1 @@ -1043,7 +1048,6 @@ 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") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ee00ffc1e1..f459db4f2a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,8 @@ serialization_json = "1.6.3" showkase = "1.0.2" appyx = "1.4.0" sqldelight = "2.0.2" -wysiwyg = "2.37.2" +# TODO: revert snapshot +wysiwyg = "2.37.2-SNAPSHOT" telephoto = "0.11.2" # DI diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index f8a83916064..6883939a2dc 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -17,9 +17,6 @@ package io.element.android.libraries.textcomposer import android.net.Uri -import android.text.Spannable -import android.text.Spanned -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -83,9 +80,7 @@ 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.textInputRoundedCornerShape -import io.element.android.libraries.textcomposer.mentions.MentionSpan 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 @@ -111,7 +106,7 @@ fun TextComposer( enableVoiceMessages: Boolean, currentUserId: UserId, onRequestFocus: () -> Unit, - onSendMessage: (Message) -> Unit, + onSendMessage: () -> Unit, onResetComposerMode: () -> Unit, onAddAttachment: () -> Unit, onDismissTextFormatting: () -> Unit, @@ -132,10 +127,7 @@ fun TextComposer( is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown } val onSendClicked = { - val html = if (state is TextEditorState.Rich && enableTextFormatting) { - state.richTextEditorState.messageHtml - } else null - onSendMessage(Message(html = html, markdown = charSequenceToMarkdown(markdown))) + onSendMessage() } val onPlayVoiceMessageClicked = { @@ -193,6 +185,7 @@ fun TextComposer( } is TextEditorState.Markdown -> { @Composable { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) MarkdownTextInput( state = state.state, subcomposing = subcomposing, @@ -201,6 +194,7 @@ fun TextComposer( modifier = Modifier.fillMaxSize(), composerMode = composerMode, onResetComposerMode = onResetComposerMode, + richTextEditorStyle = style, ) } } @@ -630,25 +624,6 @@ private fun ReplyToModeView( } } -private fun charSequenceToMarkdown(charSequence: CharSequence): String { - return if (charSequence is Spanned) { - val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java) - buildString { - append(charSequence) - for (mention in mentions.reversed()) { - val start = charSequence.getSpanStart(mention) - val end = charSequence.getSpanEnd(mention) - // TODO: Use permalinkBuilder instead - if (mention.type == MentionSpan.Type.USER) { - replace(start, end, "[${mention.text}](https://matrix.to/#/${mention.rawValue})") - } - } - } - } else { - charSequence.toString() - } -} - @PreviewsDayNight @Composable internal fun TextComposerSimplePreview() = ElementPreview { diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt index c1f2055a0c7..df736e8ab84 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt @@ -58,6 +58,8 @@ 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.ui.strings.CommonStrings +import io.element.android.wysiwyg.compose.RichTextEditorStyle +import io.element.android.wysiwyg.compose.internal.applyStyleInCompose import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -69,6 +71,7 @@ fun MarkdownTextInput( onSuggestionReceived: (Suggestion?) -> Unit, composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, + richTextEditorStyle: RichTextEditorStyle, modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() @@ -144,11 +147,13 @@ fun MarkdownTextInput( state.currentMentionSuggestion = editableText.checkSuggestionNeeded() onSuggestionReceived(state.currentMentionSuggestion) } - state.onRequestFocus = { this.requestFocus() } + state.requestFocusAction = { this.requestFocus() } } } }, update = { editText -> + editText.applyStyleInCompose(richTextEditorStyle) + if (state.text.needsDisplaying()) { editText.editableText.clear() editText.editableText.append(state.text.value()) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt similarity index 71% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt index b2977bd5084..7cf5787c496 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt @@ -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 Room : ResolvedMentionSuggestion + data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt index 7cd26eded62..5cb9c44460a 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -16,12 +16,23 @@ 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.ImmutableCharSequence +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.wysiwyg.compose.RichTextEditorState sealed interface TextEditorState { @@ -33,6 +44,16 @@ sealed interface TextEditorState { 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 @@ -50,7 +71,7 @@ sealed interface TextEditorState { suspend fun requestFocus() { when (this) { - is Markdown -> state.onRequestFocus() + is Markdown -> state.requestFocusAction() is Rich -> richTextEditorState.requestFocus() } } @@ -68,7 +89,76 @@ class MarkdownTextEditorState( var text by mutableStateOf(ImmutableCharSequence(initialText ?: "")) var selection by mutableStateOf(0..0) var hasFocus by mutableStateOf(false) - var onRequestFocus by mutableStateOf({}) + var requestFocusAction by mutableStateOf({}) var lineCount by mutableIntStateOf(1) var currentMentionSuggestion by mutableStateOf(null) + + fun insertMention( + mention: ResolvedMentionSuggestion, + mentionSpanProvider: MentionSpanProvider, + permalinkBuilder: PermalinkBuilder, + ) { + when (mention) { + is ResolvedMentionSuggestion.Room -> { + val suggestion = currentMentionSuggestion ?: return + 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 suggestion = currentMentionSuggestion ?: return + 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) + for (mention in mentions.reversed()) { + val start = charSequence.getSpanStart(mention) + val end = charSequence.getSpanEnd(mention) + if (mention.type == MentionSpan.Type.USER) { + val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue + replace(start, end, "[${mention.text}]($link)") + } + } + } + } else { + charSequence.toString() + } + } + + fun getMentions(): List { + val text = SpannableString(text.value()) + val mentionSpans = text.getSpans(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 + } + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index cd3689abe37..acfde8d1767 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,6 +27,8 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + // TODO: revert mavenLocal + mavenLocal() google() mavenCentral() maven { url = URI("https://oss.sonatype.org/content/repositories/snapshots/") } From 698fd95ac8eaeb2af8b528b090356f0376330b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 14 May 2024 13:49:34 +0200 Subject: [PATCH 04/20] Changes: - 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 tests --- .../appconfig/MessageComposerConfig.kt | 24 +++ .../messages/impl/MessagesPresenter.kt | 13 +- .../features/messages/impl/MessagesView.kt | 1 - .../messagecomposer/MessageComposerEvents.kt | 1 - .../MessageComposerPresenter.kt | 28 ++- .../messagecomposer/MessageComposerState.kt | 6 +- .../messagecomposer/MessageComposerView.kt | 6 - .../messages/impl/MessagesPresenterTest.kt | 3 +- .../MessageComposerPresenterTest.kt | 1 - .../impl/advanced/AdvancedSettingsEvents.kt | 1 - .../advanced/AdvancedSettingsPresenter.kt | 7 - .../impl/advanced/AdvancedSettingsState.kt | 1 - .../advanced/AdvancedSettingsStateProvider.kt | 3 - .../impl/advanced/AdvancedSettingsView.kt | 12 -- .../advanced/AdvancedSettingsPresenterTest.kt | 16 -- .../api/store/AppPreferencesStore.kt | 3 - .../impl/store/DefaultAppPreferencesStore.kt | 13 -- .../test/InMemoryAppPreferencesStore.kt | 10 - .../textcomposer/ComposerModeView.kt | 184 ++++++++++++++++ .../libraries/textcomposer/TextComposer.kt | 196 +++--------------- .../components/MarkdownTextInput.kt | 4 +- .../textcomposer/model/TextEditorState.kt | 27 ++- 22 files changed, 279 insertions(+), 281 deletions(-) create mode 100644 appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt new file mode 100644 index 00000000000..a927064f3df --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index e5127ea865e..579ea87868b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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.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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 1fe116b26ea..e7b65f353ff 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -439,7 +439,6 @@ private fun MessagesViewComposerBottomSheetContents( state = state.composerState, voiceMessageState = state.voiceMessageComposerState, subcomposing = subcomposing, - enableTextFormatting = state.enableTextFormatting, enableVoiceMessages = state.enableVoiceMessages, modifier = Modifier.fillMaxWidth(), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 18081236f82..19ca038bd2d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.messagecomposer import android.net.Uri import androidx.compose.runtime.Immutable import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion -import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 66c382195c3..7e5391f62ec 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -124,7 +124,10 @@ class MessageComposerPresenter @Inject constructor( @Composable override fun present(): MessageComposerState { val localCoroutineScope = rememberCoroutineScope() - val markdownTextEditorState = remember { MarkdownTextEditorState() } + + // Initially disabled so we don't set focus and text twice + var applyFormattingModeChanges by remember { mutableStateOf(false) } + val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null) } var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -162,6 +165,9 @@ class MessageComposerPresenter @Inject constructor( mutableStateOf(false) } val richTextEditorState = richTextEditorStateFactory.create() + if (isTesting) { + richTextEditorState.isReadyToProcessActions = true + } val ongoingSendAttachmentJob = remember { mutableStateOf(null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } @@ -248,19 +254,21 @@ class MessageComposerPresenter @Inject constructor( } LaunchedEffect(showTextFormatting) { + if (!applyFormattingModeChanges) { + applyFormattingModeChanges = true + return@LaunchedEffect + } if (showTextFormatting) { val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) - if (markdown.isNotEmpty()) { - richTextEditorState.setMarkdown(markdown) - richTextEditorState.requestFocus() - } + richTextEditorState.setMarkdown(markdown) + // Give some time for the focus of the previous editor to be cleared + richTextEditorState.requestFocus() } else { val markdown = richTextEditorState.messageMarkdown - if (markdown.isNotEmpty()) { - markdownTextEditorState.text.update(markdown, true) - delay(50) - markdownTextEditorState.requestFocusAction() - } + markdownTextEditorState.text.update(markdown, true) + // Give some time for the focus of the previous editor to be cleared + delay(100) + markdownTextEditorState.requestFocusAction() } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index bbcb8aad438..4ac69ed3d6a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -19,9 +19,9 @@ 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.libraries.textcomposer.mentions.ResolvedMentionSuggestion 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.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.ImmutableList @@ -40,9 +40,7 @@ data class MessageComposerState( val memberSuggestions: ImmutableList, val currentUserId: UserId, val eventSink: (MessageComposerEvents) -> Unit, -) { - val hasFocus: Boolean = textEditorState.hasFocus() -} +) @Immutable sealed interface AttachmentsState { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 631b2338280..207d8117074 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -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,7 +43,6 @@ internal fun MessageComposerView( state: MessageComposerState, voiceMessageState: VoiceMessageComposerState, subcomposing: Boolean, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, modifier: Modifier = Modifier, ) { @@ -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, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 28d089799e7..2b5f9d565ad 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -754,7 +754,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, @@ -829,7 +829,6 @@ class MessagesPresenterTest { messageSummaryFormatter = FakeMessageSummaryFormatter(), navigator = navigator, clipboardHelper = clipboardHelper, - appPreferencesStore = appPreferencesStore, featureFlagsService = FakeFeatureFlagService(), buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 032e1993081..458c1e82b37 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -77,7 +77,6 @@ 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.mentions.ResolvedMentionSuggestion -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.SuggestionType diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 8ee433f630a..ab4987f9d90 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -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 diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 2f0c2b74176..67532574b00 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -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, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 469f8a630cc..527515d867c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -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, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 5e255fc0911..fb6f2a2659b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -23,7 +23,6 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider get() = sequenceOf( aAdvancedSettingsState(), - aAdvancedSettingsState(isRichTextEditorEnabled = true), aAdvancedSettingsState(isDeveloperModeEnabled = true), aAdvancedSettingsState(showChangeThemeDialog = true), aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true), @@ -31,12 +30,10 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider - suspend fun setDeveloperModeEnabled(enabled: Boolean) fun isDeveloperModeEnabledFlow(): Flow diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index fdbd7dde8cb..f5d7beab536 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -48,19 +48,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 { - return store.data.map { prefs -> - // enabled by default - prefs[richTextEditorKey].orTrue() - } - } - override suspend fun setDeveloperModeEnabled(enabled: Boolean) { store.edit { prefs -> prefs[developerModeKey] = enabled diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index e29c4758ca0..25563d59eb9 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -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 { - return isRichTextEditorEnabled - } - override suspend fun setDeveloperModeEnabled(enabled: Boolean) { isDeveloperModeEnabled.value = enabled } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt new file mode 100644 index 00000000000..c50c81fb87a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt @@ -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) + ), + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 6883939a2dc..b6d542c50e2 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -102,7 +102,6 @@ fun TextComposer( voiceMessageState: VoiceMessageState, permalinkParser: PermalinkParser, composerMode: MessageComposerMode, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, currentUserId: UserId, onRequestFocus: () -> Unit, @@ -490,140 +489,6 @@ private fun TextInput( } } -@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) - ), - ) - } -} - @PreviewsDayNight @Composable internal fun TextComposerSimplePreview() = ElementPreview { @@ -631,43 +496,41 @@ internal fun TextComposerSimplePreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(initialText = "", initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(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.Rich(aRichTextEditorState(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.Rich( + aRichTextEditorState( + 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.Rich(aRichTextEditorState(initialText = "A message without focus")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -681,33 +544,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") ) @@ -719,10 +581,9 @@ 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")), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -736,7 +597,7 @@ internal fun TextComposerReplyPreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -747,14 +608,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, @@ -765,14 +625,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, @@ -786,14 +645,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, @@ -807,14 +665,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, @@ -828,14 +685,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, @@ -849,7 +705,6 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "Shared location" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -865,10 +720,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") ) @@ -927,24 +781,22 @@ 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( // TODO: fix this - state = TextEditorState.Rich(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 = {}, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt index df736e8ab84..a0e3d79c017 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt @@ -21,7 +21,7 @@ import android.graphics.Color import android.text.Editable import android.text.Selection import android.text.SpannableString -import android.widget.EditText +import androidx.appcompat.widget.AppCompatEditText import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -200,7 +200,7 @@ private fun Editable.checkSuggestionNeeded(): Suggestion? { class MarkdownEditText( context: Context, -) : EditText(context) { +) : AppCompatEditText(context) { var onSelectionChangeListener: ((Int, Int) -> Unit)? = null private var isModifyingText = false diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt index 5cb9c44460a..e4f20d51ead 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -20,6 +20,8 @@ import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -35,6 +37,7 @@ import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.wysiwyg.compose.RichTextEditorState +@Immutable sealed interface TextEditorState { data class Markdown( val state: MarkdownTextEditorState, @@ -59,6 +62,12 @@ sealed interface TextEditorState { is Rich -> richTextEditorState.hasFocus } + @Composable + fun focusState(): Boolean = when (this) { + is Markdown -> state.hasFocus + is Rich -> richTextEditorState.hasFocus + } + suspend fun reset() { when (this) { is Markdown -> { @@ -84,7 +93,7 @@ sealed interface TextEditorState { @Stable class MarkdownTextEditorState( - initialText: String? = null, + initialText: String?, ) { var text by mutableStateOf(ImmutableCharSequence(initialText ?: "")) var selection by mutableStateOf(0..0) @@ -130,13 +139,15 @@ class MarkdownTextEditorState( return if (charSequence is Spanned) { val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java) buildString { - append(charSequence) - for (mention in mentions.reversed()) { - val start = charSequence.getSpanStart(mention) - val end = charSequence.getSpanEnd(mention) - if (mention.type == MentionSpan.Type.USER) { - val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue - replace(start, end, "[${mention.text}]($link)") + 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) { + val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue + replace(start, end, "[${mention.text}]($link)") + } } } } From 070f1e4603715ffa0ca5da14641d7ea9fc51c87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 14 May 2024 13:49:47 +0200 Subject: [PATCH 05/20] Use snapshot version so the CI can at least run --- gradle/libs.versions.toml | 2 +- settings.gradle.kts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f459db4f2a2..f152c5a58f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ showkase = "1.0.2" appyx = "1.4.0" sqldelight = "2.0.2" # TODO: revert snapshot -wysiwyg = "2.37.2-SNAPSHOT" +wysiwyg = "2.37.2-markdown-SNAPSHOT" telephoto = "0.11.2" # DI diff --git a/settings.gradle.kts b/settings.gradle.kts index acfde8d1767..dd48d5d4af6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,8 +27,15 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - // TODO: revert mavenLocal - mavenLocal() + // 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/") } From d13927af4e011c0afa01ddfe10e0a48a2acc4aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 14 May 2024 16:15:19 +0200 Subject: [PATCH 06/20] Fix lint issues --- .../messagecomposer/MessageComposerPresenter.kt | 6 ++++-- .../MessageComposerStateProvider.kt | 2 +- .../impl/store/DefaultAppPreferencesStore.kt | 2 -- .../libraries/textcomposer/TextComposer.kt | 12 ------------ .../textcomposer/components/MarkdownTextInput.kt | 16 +++++++++++----- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 7e5391f62ec..3b4ad735e9e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -295,7 +295,9 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerEvents.SendMessage -> { val html = if (showTextFormatting) { richTextEditorState.messageHtml - } else null + } else { + null + } val markdown = if (showTextFormatting) { richTextEditorState.messageMarkdown } else { @@ -424,7 +426,7 @@ class MessageComposerPresenter @Inject constructor( } return MessageComposerState( - textEditorState = textEditorState, + textEditorState = textEditorState, permalinkParser = permalinkParser, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index cfb2b8a3305..a6b147055e1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -17,11 +17,11 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion 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.libraries.textcomposer.model.TextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index f5d7beab536..95b455a99fd 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -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 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") diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index b6d542c50e2..9f154f421a5 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -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,9 +32,6 @@ 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.getValue @@ -45,18 +40,14 @@ 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 @@ -65,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 @@ -190,7 +180,6 @@ fun TextComposer( subcomposing = subcomposing, onTyping = onTyping, onSuggestionReceived = onSuggestionReceived, - modifier = Modifier.fillMaxSize(), composerMode = composerMode, onResetComposerMode = onResetComposerMode, richTextEditorStyle = style, @@ -789,7 +778,6 @@ private fun ATextComposer( showTextFormatting: Boolean = false, ) { TextComposer( - // TODO: fix this state = state, showTextFormatting = showTextFormatting, voiceMessageState = voiceMessageState, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt index a0e3d79c017..e23809dc474 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.runtime.Composable @@ -63,6 +64,7 @@ import io.element.android.wysiwyg.compose.internal.applyStyleInCompose import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +@Suppress("ModifierMissing") @Composable fun MarkdownTextInput( state: MarkdownTextEditorState, @@ -72,7 +74,6 @@ fun MarkdownTextInput( composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, richTextEditorStyle: RichTextEditorStyle, - modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() @@ -121,7 +122,9 @@ fun MarkdownTextInput( } AndroidView( - modifier = modifier, + modifier = Modifier + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), factory = { context -> MarkdownEditText(context).apply { setPadding(0) @@ -158,9 +161,12 @@ fun MarkdownTextInput( editText.editableText.clear() editText.editableText.append(state.text.value()) } - if ((editText.selectionStart != state.selection.first && state.selection.first in 0..editText.editableText.length || - editText.selectionEnd != state.selection.last) && state.selection.last in 0..editText.editableText.length) { - println("Changing selection to ${state.selection}") + 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) } } From 67726d71761e5bdc92a94205ad2de39def308583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 14 May 2024 16:16:23 +0200 Subject: [PATCH 07/20] Fix MentionSpanProvider --- .../libraries/textcomposer/mentions/MentionSpanProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt index efdaf685215..f7da518feba 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -173,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 { From 70eccfcb83d3d5ecbf333add80ad929cceeb5600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 14 May 2024 20:24:23 +0200 Subject: [PATCH 08/20] Some cleanup and new tests --- ...est.kt => JoinAtRoomLoadedFlowNodeTest.kt} | 2 +- ...t => LoadingAtRoomStateFlowFactoryTest.kt} | 2 +- .../mentions/MentionSuggestionsPickerView.kt | 10 +- .../mentions/MentionSuggestionsProcessor.kt | 2 +- .../MessageComposerPresenter.kt | 4 +- .../MessageComposerPresenterTest.kt | 4 +- .../test/permalink/FakePermalinkBuilder.kt | 4 +- .../android/libraries/testtags/TestTags.kt | 5 + libraries/textcomposer/impl/build.gradle.kts | 9 +- .../libraries/textcomposer/TextComposer.kt | 117 +++++---- .../components/MarkdownTextInput.kt | 239 ------------------ .../markdown/ImmutableCharSequence.kt | 36 +++ .../components/markdown/MarkdownEditText.kt | 41 +++ .../components/markdown/MarkdownTextInput.kt | 169 +++++++++++++ .../mentions/ResolvedMentionSuggestion.kt | 2 +- .../model/MarkdownTextEditorState.kt | 122 +++++++++ .../textcomposer/model/TextEditorState.kt | 106 -------- .../markdown/MarkdownTextInputTest.kt | 124 +++++++++ .../impl/model/MarkdownTextEditorStateTest.kt | 171 +++++++++++++ 19 files changed, 765 insertions(+), 404 deletions(-) rename appnav/src/test/kotlin/io/element/android/appnav/{JoinRoomLoadedFlowNodeTest.kt => JoinAtRoomLoadedFlowNodeTest.kt} (99%) rename appnav/src/test/kotlin/io/element/android/appnav/room/{LoadingRoomStateFlowFactoryTest.kt => LoadingAtRoomStateFlowFactoryTest.kt} (98%) delete mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt create mode 100644 libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt create mode 100644 libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinAtRoomLoadedFlowNodeTest.kt similarity index 99% rename from appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/JoinAtRoomLoadedFlowNodeTest.kt index 93e727da754..b9c3cccb04e 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinAtRoomLoadedFlowNodeTest.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class JoinRoomLoadedFlowNodeTest { +class JoinAtRoomLoadedFlowNodeTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingAtRoomStateFlowFactoryTest.kt similarity index 98% rename from appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/room/LoadingAtRoomStateFlowFactoryTest.kt index cb8ed2da329..be04ada666f 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingAtRoomStateFlowFactoryTest.kt @@ -29,7 +29,7 @@ import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import kotlinx.coroutines.test.runTest import org.junit.Test -class LoadingRoomStateFlowFactoryTest { +class LoadingAtRoomStateFlowFactoryTest { @Test fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest { val room = FakeMatrixRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt index a1165b362c1..6639910ec99 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt @@ -63,7 +63,7 @@ fun MentionSuggestionsPickerView( memberSuggestions, key = { suggestion -> when (suggestion) { - is ResolvedMentionSuggestion.Room -> "@room" + is ResolvedMentionSuggestion.AtRoom -> "@room" is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value } } @@ -95,7 +95,7 @@ private fun RoomMemberSuggestionItemView( Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { val avatarSize = AvatarSize.TimelineRoom val avatarData = when (memberSuggestion) { - is ResolvedMentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) + is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is ResolvedMentionSuggestion.Member -> AvatarData( memberSuggestion.roomMember.userId.value, memberSuggestion.roomMember.displayName, @@ -104,12 +104,12 @@ private fun RoomMemberSuggestionItemView( ) } val title = when (memberSuggestion) { - is ResolvedMentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title) + is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName } val subtitle = when (memberSuggestion) { - is ResolvedMentionSuggestion.Room -> "@room" + is ResolvedMentionSuggestion.AtRoom -> "@room" is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value } @@ -160,7 +160,7 @@ internal fun MentionSuggestionsPickerViewPreview() { roomName = "Room", roomAvatarData = null, memberSuggestions = persistentListOf( - ResolvedMentionSuggestion.Room, + ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(roomMember), ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt index 232f4b8dc63..f0e89c1148a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt @@ -100,7 +100,7 @@ object MentionSuggestionsProcessor { .map(ResolvedMentionSuggestion::Member) if ("room".contains(query) && canSendRoomMention) { - listOf(ResolvedMentionSuggestion.Room) + matchingMembers + listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers } else { matchingMembers } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 3b4ad735e9e..319a7cf3506 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -127,7 +127,7 @@ class MessageComposerPresenter @Inject constructor( // Initially disabled so we don't set focus and text twice var applyFormattingModeChanges by remember { mutableStateOf(false) } - val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null) } + val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null, initialFocus = false) } var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -401,7 +401,7 @@ class MessageComposerPresenter @Inject constructor( localCoroutineScope.launch { if (showTextFormatting) { when (val mention = event.mention) { - is ResolvedMentionSuggestion.Room -> { + is ResolvedMentionSuggestion.AtRoom -> { richTextEditorState.insertAtRoomMentionAtSuggestion() } is ResolvedMentionSuggestion.Member -> { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 458c1e82b37..ef95cd60525 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -781,11 +781,11 @@ 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(ResolvedMentionSuggestion.Room, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.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(ResolvedMentionSuggestion.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"))) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt index 273899e762c..f700c3b6af6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt @@ -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 = { Result.failure(Exception("Not implemented")) } + private val result: (UserId) -> Result = { Result.failure(Exception("Not implemented")) } ) : PermalinkBuilder { override fun permalinkForUser(userId: UserId): Result { - return result() + return result(userId) } } diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 3046ba33728..7a504023c71 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -74,6 +74,11 @@ object TestTags { */ val richTextEditor = TestTag("rich_text_editor") + /** + * EditText inside the MarkdownTextInput. + */ + val plainTextEditor = TestTag("plain_text_editor") + /** * Message bubble. */ diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts index 0bbb508b3cb..21fd1247652 100644 --- a/libraries/textcomposer/impl/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -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) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 9f154f421a5..af8f897a367 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -62,13 +62,14 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.textcomposer.components.ComposerOptionsButton import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton -import io.element.android.libraries.textcomposer.components.MarkdownTextInput import io.element.android.libraries.textcomposer.components.SendButton import io.element.android.libraries.textcomposer.components.TextFormatting import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton import io.element.android.libraries.textcomposer.components.VoiceMessagePreview import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton import io.element.android.libraries.textcomposer.components.VoiceMessageRecording +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.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.MessageComposerMode @@ -147,6 +148,11 @@ fun TextComposer( val textInput: @Composable () -> Unit = when (state) { is TextEditorState.Rich -> { + val placeholder = if (composerMode.inThread) { + stringResource(id = CommonStrings.action_reply_in_thread) + } else { + stringResource(id = R.string.rich_text_editor_composer_placeholder) + } remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { @Composable { val mentionSpanProvider = rememberMentionSpanProvider( @@ -156,11 +162,7 @@ fun TextComposer( TextInput( state = state.richTextEditorState, subcomposing = subcomposing, - placeholder = if (composerMode.inThread) { - stringResource(id = CommonStrings.action_reply_in_thread) - } else { - stringResource(id = R.string.rich_text_editor_composer_placeholder) - }, + placeholder = placeholder, composerMode = composerMode, onResetComposerMode = onResetComposerMode, resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, @@ -173,17 +175,28 @@ fun TextComposer( } } is TextEditorState.Markdown -> { + val placeholder = if (composerMode.inThread) { + stringResource(id = CommonStrings.action_reply_in_thread) + } else { + stringResource(id = R.string.rich_text_editor_composer_placeholder) + } @Composable { val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) - MarkdownTextInput( - state = state.state, - subcomposing = subcomposing, - onTyping = onTyping, - onSuggestionReceived = onSuggestionReceived, + TextInputBox( composerMode = composerMode, onResetComposerMode = onResetComposerMode, - richTextEditorStyle = style, - ) + placeholder = placeholder, + showPlaceholder = { state.state.text.value().isEmpty() }, + ) { + MarkdownTextInput( + state = state.state, + subcomposing = subcomposing, + onTyping = onTyping, + onSuggestionReceived = onSuggestionReceived, + composerMode = composerMode, + richTextEditorStyle = style, + ) + } } } } @@ -413,17 +426,12 @@ 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, + textInput: @Composable () -> Unit, ) { val bgColor = ElementTheme.colors.bgSubtleSecondary val borderColor = ElementTheme.colors.borderDisabled @@ -448,7 +456,7 @@ private fun TextInput( contentAlignment = Alignment.CenterStart, ) { // Placeholder - if (state.messageHtml.isEmpty()) { + if (showPlaceholder()) { Text( placeholder, style = defaultTypography.copy( @@ -459,25 +467,48 @@ 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 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)?, +) { + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + placeholder = placeholder, + showPlaceholder = { state.messageHtml.isEmpty() } + ) { + 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, + ) + } +} + @PreviewsDayNight @Composable internal fun TextComposerSimplePreview() = ElementPreview { @@ -485,7 +516,7 @@ internal fun TextComposerSimplePreview() = ElementPreview { items = persistentListOf( { ATextComposer( - TextEditorState.Rich(aRichTextEditorState(initialText = "", initialFocus = true)), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, enableVoiceMessages = true, @@ -494,7 +525,7 @@ internal fun TextComposerSimplePreview() = ElementPreview { }, { ATextComposer( - TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, enableVoiceMessages = true, @@ -503,8 +534,8 @@ internal fun TextComposerSimplePreview() = ElementPreview { }, { ATextComposer( - TextEditorState.Rich( - aRichTextEditorState( + TextEditorState.Markdown( + aMarkdownTextEditorState( initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", initialFocus = true ) @@ -517,7 +548,7 @@ internal fun TextComposerSimplePreview() = ElementPreview { }, { ATextComposer( - TextEditorState.Rich(aRichTextEditorState(initialText = "A message without focus")), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, enableVoiceMessages = true, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt deleted file mode 100644 index e23809dc474..00000000000 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/MarkdownTextInput.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * 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 - -import android.content.Context -import android.graphics.Color -import android.text.Editable -import android.text.Selection -import android.text.SpannableString -import androidx.appcompat.widget.AppCompatEditText -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredHeightIn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -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.compound.theme.ElementTheme -import io.element.android.libraries.core.extensions.orEmpty -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.testtags.TestTags -import io.element.android.libraries.testtags.testTag -import io.element.android.libraries.textcomposer.ComposerModeView -import io.element.android.libraries.textcomposer.R -import io.element.android.libraries.textcomposer.mentions.MentionSpan -import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState -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.ui.strings.CommonStrings -import io.element.android.wysiwyg.compose.RichTextEditorStyle -import io.element.android.wysiwyg.compose.internal.applyStyleInCompose -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Suppress("ModifierMissing") -@Composable -fun MarkdownTextInput( - state: MarkdownTextEditorState, - subcomposing: Boolean, - onTyping: (Boolean) -> Unit, - onSuggestionReceived: (Suggestion?) -> Unit, - composerMode: MessageComposerMode, - onResetComposerMode: () -> Unit, - richTextEditorStyle: RichTextEditorStyle, -) { - val coroutineScope = rememberCoroutineScope() - - val bgColor = ElementTheme.colors.bgSubtleSecondary - val borderColor = ElementTheme.colors.borderDisabled - val roundedCorners = textInputRoundedCornerShape(composerMode = composerMode) - - LaunchedEffect(composerMode) { - if (composerMode is MessageComposerMode.Edit) { - state.text.update(composerMode.defaultContent, true) - } - } - - Column( - modifier = Modifier - .clip(roundedCorners) - .border(0.5.dp, borderColor, roundedCorners) - .background(color = bgColor) - .requiredHeightIn(min = 42.dp) - .fillMaxSize(), - ) { - if (composerMode is MessageComposerMode.Special) { - ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode) - } - val defaultTypography = ElementTheme.typography.fontBodyLgRegular - Box( - modifier = Modifier - .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) - .testTag(TestTags.richTextEditor), - contentAlignment = Alignment.CenterStart, - ) { - // Placeholder - if (state.text.value().isEmpty()) { - Text( - text = if (composerMode.inThread) { - stringResource(id = CommonStrings.action_reply_in_thread) - } else { - stringResource(id = R.string.rich_text_editor_composer_placeholder) - }, - style = defaultTypography.copy( - color = ElementTheme.colors.textSecondary, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - AndroidView( - modifier = Modifier - .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), - factory = { context -> - MarkdownEditText(context).apply { - setPadding(0) - setBackgroundColor(Color.TRANSPARENT) - setText(state.text.value()) - if (!subcomposing) { - setSelection(state.selection.first, state.selection.last) - setOnFocusChangeListener { _, hasFocus -> - state.hasFocus = hasFocus - } - addTextChangedListener { editable -> - coroutineScope.launch(Dispatchers.Main) { - 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.editableText.clear() - editText.editableText.append(state.text.value()) - } - 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 (getSpans(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 -> return null - } - Suggestion(startOfWord, endOfWord, suggestionType, text) - } else { - null - } -} - -class MarkdownEditText( - context: Context, -) : AppCompatEditText(context) { - var onSelectionChangeListener: ((Int, Int) -> Unit)? = null - - private var 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) - } - } -} - -class ImmutableCharSequence(initialText: CharSequence = "") { - private var value by mutableStateOf(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 -} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt new file mode 100644 index 00000000000..a528210964e --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt @@ -0,0 +1,36 @@ +/* + * 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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.element.android.libraries.core.extensions.orEmpty + +class ImmutableCharSequence(initialText: CharSequence = "") { + private var value by mutableStateOf(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 +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt new file mode 100644 index 00000000000..54bb87b0468 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -0,0 +1,41 @@ +/* + * 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 + + 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) + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt new file mode 100644 index 00000000000..f11012ba8ad --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -0,0 +1,169 @@ +/* + * 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.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +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.MessageComposerMode +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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Suppress("ModifierMissing") +@Composable +fun MarkdownTextInput( + state: MarkdownTextEditorState, + subcomposing: Boolean, + onTyping: (Boolean) -> Unit, + onSuggestionReceived: (Suggestion?) -> Unit, + composerMode: MessageComposerMode, + richTextEditorStyle: RichTextEditorStyle, +) { + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(composerMode) { + if (composerMode is MessageComposerMode.Edit) { + state.text.update(composerMode.defaultContent, true) + } + } + + 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 (!subcomposing) { + setSelection(state.selection.first, state.selection.last) + setOnFocusChangeListener { _, hasFocus -> + state.hasFocus = hasFocus + } + addTextChangedListener { editable -> + coroutineScope.launch(Dispatchers.Main) { + 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.editableText.clear() + editText.editableText.append(state.text.value()) + } + 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 (getSpans(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 -> return null + } + 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 = {}, + composerMode = MessageComposerMode.Normal, + richTextEditorStyle = style, + ) + } +} + +internal fun aMarkdownTextEditorState( + initialText: String = "Hello, World!", + initialFocus: Boolean = true, +) = MarkdownTextEditorState( + initialText = initialText, + initialFocus = initialFocus, +) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt index 7cf5787c496..03bc48f53df 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt @@ -21,6 +21,6 @@ import io.element.android.libraries.matrix.api.room.RoomMember @Immutable sealed interface ResolvedMentionSuggestion { - data object Room : ResolvedMentionSuggestion + data object AtRoom : ResolvedMentionSuggestion data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt new file mode 100644 index 00000000000..ee15959424a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -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.ImmutableCharSequence +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(ImmutableCharSequence(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(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 { + val text = SpannableString(text.value()) + val mentionSpans = text.getSpans(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 + } + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt index e4f20d51ead..ae7a15fb65d 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -16,25 +16,8 @@ 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.Composable import androidx.compose.runtime.Immutable -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.ImmutableCharSequence -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.wysiwyg.compose.RichTextEditorState @Immutable @@ -62,12 +45,6 @@ sealed interface TextEditorState { is Rich -> richTextEditorState.hasFocus } - @Composable - fun focusState(): Boolean = when (this) { - is Markdown -> state.hasFocus - is Rich -> richTextEditorState.hasFocus - } - suspend fun reset() { when (this) { is Markdown -> { @@ -90,86 +67,3 @@ sealed interface TextEditorState { is Rich -> richTextEditorState.lineCount } } - -@Stable -class MarkdownTextEditorState( - initialText: String?, -) { - var text by mutableStateOf(ImmutableCharSequence(initialText ?: "")) - var selection by mutableStateOf(0..0) - var hasFocus by mutableStateOf(false) - var requestFocusAction by mutableStateOf({}) - var lineCount by mutableIntStateOf(1) - var currentMentionSuggestion by mutableStateOf(null) - - fun insertMention( - mention: ResolvedMentionSuggestion, - mentionSpanProvider: MentionSpanProvider, - permalinkBuilder: PermalinkBuilder, - ) { - when (mention) { - is ResolvedMentionSuggestion.Room -> { - val suggestion = currentMentionSuggestion ?: return - 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 suggestion = currentMentionSuggestion ?: return - 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) { - val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue - replace(start, end, "[${mention.text}]($link)") - } - } - } - } - } else { - charSequence.toString() - } - } - - fun getMentions(): List { - val text = SpannableString(text.value()) - val mentionSpans = text.getSpans(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 - } - } - } -} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt new file mode 100644 index 00000000000..e713961769a --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -0,0 +1,124 @@ +/* + * 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.test.ext.junit.runners.AndroidJUnit4 +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.model.MarkdownTextEditorState +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.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() + + @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() + rule.setMarkdownTextInput(state = state, onTyping = onTyping) + rule.activityRule.scenario.onActivity { + val editText = it.findEditor() + editText.setText("Test") + editText.setText("") + } + rule.awaitIdle() + onTyping.assertList(listOf(true, 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() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onSuggestionReceived.assertList(listOf(null, null)) // Initial value and after typing + } + + @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() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("@Al") + } + rule.awaitIdle() + onSuggestionReceived.assertList( + listOf( + // From setting text + Suggestion(0, 3, SuggestionType.Mention, "Al"), + // From setting selection + Suggestion(0, 3, SuggestionType.Mention, "Al"), + ) + ) + } + + private fun AndroidComposeTestRule.setMarkdownTextInput( + state: MarkdownTextEditorState = aMarkdownTextEditorState(), + subcomposing: Boolean = false, + onTyping: (Boolean) -> Unit = {}, + onSuggestionReceived: (Suggestion?) -> Unit = {}, + composerMode: MessageComposerMode = MessageComposerMode.Normal, + ) { + rule.setContent { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) + MarkdownTextInput( + state = state, + subcomposing = subcomposing, + onTyping = onTyping, + onSuggestionReceived = onSuggestionReceived, + composerMode = composerMode, + richTextEditorStyle = style, + ) + } + } + + private fun ComponentActivity.findEditor(): EditText { + return window.decorView.findViewWithTag(TestTags.plainTextEditor.value) + } +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt new file mode 100644 index 00000000000..bd2b6785ede --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -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("@") + } + } + } +} From 5b877cfbe2b3492ab34938d5e77e429dbd048b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 14 May 2024 20:41:41 +0200 Subject: [PATCH 09/20] Fix simple composer screenshot --- .../io/element/android/libraries/textcomposer/TextComposer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index af8f897a367..6f85fd91b1f 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -548,7 +548,7 @@ internal fun TextComposerSimplePreview() = ElementPreview { }, { ATextComposer( - TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus")), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus", initialFocus = false)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, enableVoiceMessages = true, From 6871ae35c442e0e8f227c8bde831de20a8140eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 May 2024 09:04:29 +0200 Subject: [PATCH 10/20] Add more tests and previews --- .../MessageComposerPresenterTest.kt | 35 ++++++++- .../libraries/textcomposer/TextComposer.kt | 14 ++++ .../markdown/MarkdownTextInputTest.kt | 71 +++++++++++++++++-- .../tests/konsist/KonsistPreviewTest.kt | 1 + 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index ef95cd60525..42848bd3088 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -238,7 +238,7 @@ class MessageComposerPresenterTest { } @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() @@ -263,6 +263,35 @@ class MessageComposerPresenterTest { } } + @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( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + @Test fun `present - edit sent message`() = runTest { val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> @@ -1050,3 +1079,7 @@ fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) 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") +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 6f85fd91b1f..993cfe97736 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -610,6 +610,20 @@ internal fun TextComposerEditPreview() = ElementPreview { })) } +@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")), + enableVoiceMessages = true, + currentUserId = UserId("@alice:localhost") + ) + })) +} + @PreviewsDayNight @Composable internal fun TextComposerReplyPreview() = ElementPreview { diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index e713961769a..d641334d035 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -20,11 +20,21 @@ 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.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion @@ -62,9 +72,10 @@ class MarkdownTextInputTest { val editText = it.findEditor() editText.setText("Test") editText.setText("") + editText.setText(null) } rule.awaitIdle() - onTyping.assertList(listOf(true, false)) + onTyping.assertList(listOf(true, false, false)) } @Test @@ -85,19 +96,67 @@ class MarkdownTextInputTest { val onSuggestionReceived = EventsRecorder() rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) rule.activityRule.scenario.onActivity { - it.findEditor().setText("@Al") + it.findEditor().setText("@") + it.findEditor().setText("#") + it.findEditor().setText("/") } rule.awaitIdle() onSuggestionReceived.assertList( listOf( - // From setting text - Suggestion(0, 3, SuggestionType.Mention, "Al"), - // From setting selection - Suggestion(0, 3, SuggestionType.Mention, "Al"), + // Initial value + null, + // User mention suggestion + Suggestion(0, 1, SuggestionType.Mention, ""), + // Text cleared + null, + // Room suggestion + Suggestion(0, 1, SuggestionType.Room, ""), + // Text cleared + null, + // Slash command suggestion, not supported yet + null, ) ) } + @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 `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(0, 2).orEmpty() + assertThat(mentionSpans).isNotEmpty() + } + private fun AndroidComposeTestRule.setMarkdownTextInput( state: MarkdownTextEditorState = aMarkdownTextEditorState(), subcomposing: Boolean = false, diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 75b0df9837d..52ae3660c6c 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -78,6 +78,7 @@ class KonsistPreviewTest { "IconTitleSubtitleMoleculeWithResIconPreview", "IconsCompoundPreview", "IconsOtherPreview", + "MarkdownTextComposerEditPreview", "MentionSpanPreview", "MessageComposerViewVoicePreview", "MessagesReactionButtonAddPreview", From 7009d7ac9fd57639989b2ed760d42a2f38628078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 May 2024 09:54:39 +0200 Subject: [PATCH 11/20] Some more cleanup --- ...eTest.kt => JoinRoomLoadedFlowNodeTest.kt} | 2 +- ....kt => LoadingRoomStateFlowFactoryTest.kt} | 2 +- .../MessageComposerPresenter.kt | 15 +++-- .../libraries/textcomposer/TextComposer.kt | 1 - .../markdown/ImmutableCharSequence.kt | 6 ++ .../components/markdown/MarkdownEditText.kt | 7 +++ .../components/markdown/MarkdownTextInput.kt | 55 ++++++++----------- .../markdown/MarkdownTextInputTest.kt | 11 +--- 8 files changed, 48 insertions(+), 51 deletions(-) rename appnav/src/test/kotlin/io/element/android/appnav/{JoinAtRoomLoadedFlowNodeTest.kt => JoinRoomLoadedFlowNodeTest.kt} (99%) rename appnav/src/test/kotlin/io/element/android/appnav/room/{LoadingAtRoomStateFlowFactoryTest.kt => LoadingRoomStateFlowFactoryTest.kt} (98%) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinAtRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt similarity index 99% rename from appnav/src/test/kotlin/io/element/android/appnav/JoinAtRoomLoadedFlowNodeTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt index b9c3cccb04e..93e727da754 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinAtRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class JoinAtRoomLoadedFlowNodeTest { +class JoinRoomLoadedFlowNodeTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingAtRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt similarity index 98% rename from appnav/src/test/kotlin/io/element/android/appnav/room/LoadingAtRoomStateFlowFactoryTest.kt rename to appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt index be04ada666f..cb8ed2da329 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingAtRoomStateFlowFactoryTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt @@ -29,7 +29,7 @@ import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import kotlinx.coroutines.test.runTest import org.junit.Test -class LoadingAtRoomStateFlowFactoryTest { +class LoadingRoomStateFlowFactoryTest { @Test fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest { val room = FakeMatrixRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 319a7cf3506..47a31bf312b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -127,6 +127,10 @@ class MessageComposerPresenter @Inject constructor( // Initially disabled so we don't set focus and text twice var applyFormattingModeChanges by remember { mutableStateOf(false) } + val richTextEditorState = richTextEditorStateFactory.create() + if (isTesting) { + richTextEditorState.isReadyToProcessActions = true + } val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null, initialFocus = false) } var isMentionsEnabled by remember { mutableStateOf(false) } @@ -164,10 +168,6 @@ class MessageComposerPresenter @Inject constructor( val isFullScreen = rememberSaveable { mutableStateOf(false) } - val richTextEditorState = richTextEditorStateFactory.create() - if (isTesting) { - richTextEditorState.isReadyToProcessActions = true - } val ongoingSendAttachmentJob = remember { mutableStateOf(null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } @@ -177,8 +177,11 @@ class MessageComposerPresenter @Inject constructor( LaunchedEffect(messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) { is MessageComposerMode.Edit -> - // No need to handle this for plain text editor - richTextEditorState.setHtml(modeValue.defaultContent) + if (showTextFormatting) { + richTextEditorState.setHtml(modeValue.defaultContent) + } else { + markdownTextEditorState.text.update(modeValue.defaultContent, true) + } else -> Unit } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 993cfe97736..f0055ac78d4 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -193,7 +193,6 @@ fun TextComposer( subcomposing = subcomposing, onTyping = onTyping, onSuggestionReceived = onSuggestionReceived, - composerMode = composerMode, richTextEditorStyle = style, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt index a528210964e..cc93af4b3b7 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt @@ -17,11 +17,13 @@ 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 ImmutableCharSequence(initialText: CharSequence = "") { private var value by mutableStateOf(SpannableString(initialText)) private var needsDisplaying by mutableStateOf(false) @@ -33,4 +35,8 @@ class ImmutableCharSequence(initialText: CharSequence = "") { fun value(): CharSequence = value fun needsDisplaying(): Boolean = needsDisplaying + + override fun toString(): String { + return "ImmutableCharSequence(value='$value', needsDisplaying=$needsDisplaying)" + } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt index 54bb87b0468..98842c35de2 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -26,6 +26,13 @@ internal class MarkdownEditText( 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) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index f11012ba8ad..a996e779ff8 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -22,8 +22,6 @@ import android.text.Selection import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -36,13 +34,10 @@ 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.MessageComposerMode 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 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Suppress("ModifierMissing") @Composable @@ -51,17 +46,9 @@ fun MarkdownTextInput( subcomposing: Boolean, onTyping: (Boolean) -> Unit, onSuggestionReceived: (Suggestion?) -> Unit, - composerMode: MessageComposerMode, richTextEditorStyle: RichTextEditorStyle, ) { - val coroutineScope = rememberCoroutineScope() - - LaunchedEffect(composerMode) { - if (composerMode is MessageComposerMode.Edit) { - state.text.update(composerMode.defaultContent, true) - } - } - + val canUpdateState = !subcomposing AndroidView( modifier = Modifier .padding(top = 6.dp, bottom = 6.dp) @@ -72,20 +59,18 @@ fun MarkdownTextInput( setPadding(0) setBackgroundColor(Color.TRANSPARENT) setText(state.text.value()) - if (!subcomposing) { + if (canUpdateState) { setSelection(state.selection.first, state.selection.last) setOnFocusChangeListener { _, hasFocus -> state.hasFocus = hasFocus } addTextChangedListener { editable -> - coroutineScope.launch(Dispatchers.Main) { - onTyping(!editable.isNullOrEmpty()) - state.text.update(editable, false) - state.lineCount = lineCount + onTyping(!editable.isNullOrEmpty()) + state.text.update(editable, false) + state.lineCount = lineCount - state.currentMentionSuggestion = editable?.checkSuggestionNeeded() - onSuggestionReceived(state.currentMentionSuggestion) - } + state.currentMentionSuggestion = editable?.checkSuggestionNeeded() + onSuggestionReceived(state.currentMentionSuggestion) } onSelectionChangeListener = { selStart, selEnd -> state.selection = selStart..selEnd @@ -100,16 +85,20 @@ fun MarkdownTextInput( editText.applyStyleInCompose(richTextEditorStyle) if (state.text.needsDisplaying()) { - editText.editableText.clear() - editText.editableText.append(state.text.value()) + editText.updateEditableText(state.text.value()) + if (canUpdateState) { + state.text.update(editText.editableText, false) + } } - val newSelectionStart = state.selection.first - val newSelectionEnd = state.selection.last - val currentTextRange = 0..editText.editableText.length - val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd } - val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange } - if (didSelectionChange() && isNewSelectionValid()) { - editText.setSelection(state.selection.first, state.selection.last) + 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) + } } } ) @@ -125,7 +114,10 @@ private fun Editable.checkSuggestionNeeded(): Suggestion? { } if (startOfWord !in indices) return null val firstChar = this[startOfWord] + + // If a mention span already exists we don't need suggestions if (getSpans(startOfWord, startOfWord + 1).isNotEmpty()) return null + return if (firstChar in listOf('@', '#', "/")) { var endOfWord = end while (endOfWord < this.length && !this[endOfWord].isWhitespace()) { @@ -154,7 +146,6 @@ internal fun MarkdownTextInputPreview() { subcomposing = false, onTyping = {}, onSuggestionReceived = {}, - composerMode = MessageComposerMode.Normal, richTextEditorStyle = style, ) } diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index d641334d035..5c5d49a9db7 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -36,7 +36,6 @@ 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.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.tests.testutils.EnsureCalledOnceWithParam @@ -87,7 +86,7 @@ class MarkdownTextInputTest { it.findEditor().setText("Test") } rule.awaitIdle() - onSuggestionReceived.assertList(listOf(null, null)) // Initial value and after typing + onSuggestionReceived.assertSingle(null) } @Test @@ -103,16 +102,10 @@ class MarkdownTextInputTest { rule.awaitIdle() onSuggestionReceived.assertList( listOf( - // Initial value - null, // User mention suggestion Suggestion(0, 1, SuggestionType.Mention, ""), - // Text cleared - null, // Room suggestion Suggestion(0, 1, SuggestionType.Room, ""), - // Text cleared - null, // Slash command suggestion, not supported yet null, ) @@ -162,7 +155,6 @@ class MarkdownTextInputTest { subcomposing: Boolean = false, onTyping: (Boolean) -> Unit = {}, onSuggestionReceived: (Suggestion?) -> Unit = {}, - composerMode: MessageComposerMode = MessageComposerMode.Normal, ) { rule.setContent { val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) @@ -171,7 +163,6 @@ class MarkdownTextInputTest { subcomposing = subcomposing, onTyping = onTyping, onSuggestionReceived = onSuggestionReceived, - composerMode = composerMode, richTextEditorStyle = style, ) } From 241e3e89794c2edff41d1410082fea682053d69e Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 14 May 2024 13:58:18 +0000 Subject: [PATCH 12/20] Update screenshots --- ...ll_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png | 4 ++-- ...ll_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png | 4 ++-- ...ll_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png | 4 ++-- ...ll_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png | 4 ++-- ...ll_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png | 3 --- ..._AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png | 4 ++-- ..._AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png | 4 ++-- ..._AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png | 4 ++-- ..._AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png | 4 ++-- ..._AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png | 3 --- ..._null_MarkdownTextInput-Day-19_20_null,NEXUS_5,1.0,en].png | 3 +++ ...ull_MarkdownTextInput-Night-19_21_null,NEXUS_5,1.0,en].png | 3 +++ ...l_ComposerOptionsButton-Day-9_10_null,NEXUS_5,1.0,en].png} | 0 ...ComposerOptionsButton-Night-9_11_null,NEXUS_5,1.0,en].png} | 0 ...ssTextFormattingButton-Day-10_11_null,NEXUS_5,1.0,en].png} | 0 ...TextFormattingButton-Night-10_12_null,NEXUS_5,1.0,en].png} | 0 ..._null_FormattingOption-Day-11_12_null,NEXUS_5,1.0,en].png} | 0 ...ull_FormattingOption-Night-11_13_null,NEXUS_5,1.0,en].png} | 0 ..._null_LiveWaveformView-Day-12_13_null,NEXUS_5,1.0,en].png} | 0 ...ull_LiveWaveformView-Night-12_14_null,NEXUS_5,1.0,en].png} | 0 ...Button_null_SendButton-Day-13_14_null,NEXUS_5,1.0,en].png} | 0 ...tton_null_SendButton-Night-13_15_null,NEXUS_5,1.0,en].png} | 0 ...ng_null_TextFormatting-Day-14_15_null,NEXUS_5,1.0,en].png} | 0 ..._null_TextFormatting-Night-14_16_null,NEXUS_5,1.0,en].png} | 0 ...iceMessageDeleteButton-Day-15_16_null,NEXUS_5,1.0,en].png} | 0 ...eMessageDeleteButton-Night-15_17_null,NEXUS_5,1.0,en].png} | 0 ...ll_VoiceMessagePreview-Day-16_17_null,NEXUS_5,1.0,en].png} | 0 ..._VoiceMessagePreview-Night-16_18_null,NEXUS_5,1.0,en].png} | 0 ...eMessageRecorderButton-Day-17_18_null,NEXUS_5,1.0,en].png} | 0 ...essageRecorderButton-Night-17_19_null,NEXUS_5,1.0,en].png} | 0 ..._VoiceMessageRecording-Day-18_19_null,NEXUS_5,1.0,en].png} | 0 ...oiceMessageRecording-Night-18_20_null,NEXUS_5,1.0,en].png} | 0 ...onSpan_null_MentionSpan-Day-18_19_null,NEXUS_5,1.0,en].png | 3 --- ...onSpan_null_MentionSpan-Day-20_21_null,NEXUS_5,1.0,en].png | 3 +++ ...Span_null_MentionSpan-Night-18_20_null,NEXUS_5,1.0,en].png | 3 --- ...Span_null_MentionSpan-Night-20_22_null,NEXUS_5,1.0,en].png | 3 +++ ..._MarkdownTextComposerEdit-Day-3_4_null,NEXUS_5,1.0,en].png | 3 +++ ...arkdownTextComposerEdit-Night-3_5_null,NEXUS_5,1.0,en].png | 3 +++ ...logCreateLinkWithoutText-Day-7_8_null,NEXUS_5,1.0,en].png} | 0 ...gCreateLinkWithoutText-Night-7_9_null,NEXUS_5,1.0,en].png} | 0 ...oserLinkDialogCreateLink-Day-6_7_null,NEXUS_5,1.0,en].png} | 0 ...erLinkDialogCreateLink-Night-6_8_null,NEXUS_5,1.0,en].png} | 0 ...mposerLinkDialogEditLink-Day-8_9_null,NEXUS_5,1.0,en].png} | 0 ...serLinkDialogEditLink-Night-8_10_null,NEXUS_5,1.0,en].png} | 0 ...y_null_TextComposerReply-Day-4_5_null,NEXUS_5,1.0,en].png} | 0 ...null_TextComposerReply-Night-4_6_null,NEXUS_5,1.0,en].png} | 0 ...e_null_TextComposerVoice-Day-5_6_null,NEXUS_5,1.0,en].png} | 0 ...null_TextComposerVoice-Night-5_7_null,NEXUS_5,1.0,en].png} | 0 48 files changed, 34 insertions(+), 28 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Day-19_20_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Night-19_21_null,NEXUS_5,1.0,en].png rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Day-8_9_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Day-9_10_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Night-8_10_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Night-9_11_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Day-9_10_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Day-10_11_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Night-9_11_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Night-10_12_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Day-10_11_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Day-11_12_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Night-10_12_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Night-11_13_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Day-11_12_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Day-12_13_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Night-11_13_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Night-12_14_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Day-12_13_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Day-13_14_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Night-12_14_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Night-13_15_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Day-13_14_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Day-14_15_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Night-13_15_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Night-14_16_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Day-14_15_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Day-15_16_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Night-14_16_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Night-15_17_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Day-15_16_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Day-16_17_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Night-15_17_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Night-16_18_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Day-16_17_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Day-17_18_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Night-16_18_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Night-17_19_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Day-17_18_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Day-18_19_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Night-17_19_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Night-18_20_null,NEXUS_5,1.0,en].png} (100%) delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-18_19_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-20_21_null,NEXUS_5,1.0,en].png delete mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-18_20_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-20_22_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Day-3_4_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Night-3_5_null,NEXUS_5,1.0,en].png rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Day-6_7_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Day-7_8_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Night-6_8_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Night-7_9_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Day-5_6_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Day-6_7_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Night-5_7_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Night-6_8_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Day-7_8_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Day-8_9_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Night-7_9_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Night-8_10_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Day-3_4_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Day-4_5_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Night-3_5_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Night-4_6_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Day-4_5_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Day-5_6_null,NEXUS_5,1.0,en].png} (100%) rename tests/uitests/src/test/snapshots/images/{ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Night-4_6_null,NEXUS_5,1.0,en].png => ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Night-5_7_null,NEXUS_5,1.0,en].png} (100%) diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png index 0022fb4f2df..7522f1df811 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4931140dafa7377d1583d4af9603337fbb3a0045ddbb348bf23a220bd77083d1 -size 56388 +oid sha256:de588d3ef8770778779d09b2f883e4327c4cc3afce98d33aab32298e32ca070a +size 44474 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png index 9555fa431c7..244bcde7239 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13cffd3bf3d2eb4d266b81dd05d40e05cfd4663851ea06e9c6c24374e457fb1d -size 55859 +oid sha256:342ef5ebb8ece155939816d6ba04298b5827dd1c125891986ee3d6389c75fe64 +size 43970 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png index a693a5f7c3f..cfdce8855f2 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00cdf4f635352c11d572faae361c066986a6812f80124fe23ab142f8c87c077f -size 55888 +oid sha256:92ed07e7bea55582c88ca8c8f3918c1058a5f56a1f7a196fd8080c37bff6bc52 +size 35864 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png index bea917b2471..5c691e4c48f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4a9814198e0a6be655ec1bfb8038071e29a8ee4959b5209936e6b63d92c4e07 -size 36289 +oid sha256:4219e66ccebc52570ee2193b9a4b564a8c9c370c051a8c377e4ad12a9f3f8ffb +size 44001 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index 086b021e64d..00000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:89f646eede8992256de5df9181eccc28f0ebc3fd2d07d58e102b6cabfbf4e5e2 -size 55834 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png index 2417b336b69..47095a02b7a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9fd3a8cee62f283571b37670b4f064dd9f6c5195dec92b20c6ee3d53c9defae -size 53103 +oid sha256:706666dd4351887061879359d6947daef5ce74e552784b18bd0216b233ee6048 +size 41826 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png index 0b1d375333b..e4fc9e45872 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ad13ab2b02480a9d436fb62039139284af250aaaf806434f528307e6704162a -size 52797 +oid sha256:731f570f939af1f515f030f03420aeca39880100707e74e4ebe2e3433ac56a95 +size 41441 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png index 3d104af37fc..62c48d20500 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1584885ecab5a8aa3bbf1f0b116d74d398043d0b12c905d69717bec5b1e80ea7 -size 52819 +oid sha256:9d7cadd4ed9e918a52a7b7ab0c6416c0cb06b1d77900c68606c0f532a6b0647c +size 31551 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png index a941e8ba833..448627f3d9c 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99acb52cafefefbf3a17ce759e85b821d949df9c5d37fce4038b0d99e22993b5 -size 32144 +oid sha256:ffa635614cbf5a57c4b481c99eadaf56777342090fbc26441be3e413b16ff95b +size 41436 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png deleted file mode 100644 index 3d20590ecec..00000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fc26ca6ad489264a7008c528b762a22b6349aa9c16f6d43ed41628b20aa0659c -size 52824 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Day-19_20_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Day-19_20_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..ec896e7a810 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Day-19_20_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21d806b169f6cdce5f454c43e7877d454c4e6243af3f6de85f5c70637c8f3603 +size 7277 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Night-19_21_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Night-19_21_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..2f1ca90da91 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components.markdown_MarkdownTextInput_null_MarkdownTextInput-Night-19_21_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a22b040124c765a1ab46b91afa2df411c3da3333ff12977bff2537bd152840d +size 7001 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Day-8_9_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Day-9_10_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Day-8_9_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Day-9_10_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Night-8_10_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Night-9_11_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Night-8_10_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_ComposerOptionsButton_null_ComposerOptionsButton-Night-9_11_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Day-9_10_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Day-10_11_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Day-9_10_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Day-10_11_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Night-9_11_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Night-10_12_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Night-9_11_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_DismissTextFormattingButton_null_DismissTextFormattingButton-Night-10_12_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Day-10_11_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Day-11_12_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Day-10_11_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Day-11_12_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Night-10_12_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Night-11_13_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Night-10_12_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_FormattingOption_null_FormattingOption-Night-11_13_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Day-11_12_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Day-12_13_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Day-11_12_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Day-12_13_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Night-11_13_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Night-12_14_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Night-11_13_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_LiveWaveformView_null_LiveWaveformView-Night-12_14_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Day-12_13_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Day-13_14_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Day-12_13_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Day-13_14_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Night-12_14_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Night-13_15_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Night-12_14_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_SendButton_null_SendButton-Night-13_15_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Day-13_14_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Day-14_15_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Day-13_14_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Day-14_15_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Night-13_15_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Night-14_16_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Night-13_15_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_TextFormatting_null_TextFormatting-Night-14_16_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Day-14_15_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Day-15_16_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Day-14_15_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Day-15_16_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Night-14_16_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Night-15_17_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Night-14_16_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageDeleteButton_null_VoiceMessageDeleteButton-Night-15_17_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Day-15_16_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Day-16_17_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Day-15_16_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Day-16_17_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Night-15_17_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Night-16_18_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Night-15_17_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessagePreview_null_VoiceMessagePreview-Night-16_18_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Day-16_17_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Day-17_18_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Day-16_17_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Day-17_18_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Night-16_18_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Night-17_19_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Night-16_18_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecorderButton_null_VoiceMessageRecorderButton-Night-17_19_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Day-17_18_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Day-18_19_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Day-17_18_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Day-18_19_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Night-17_19_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Night-18_20_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Night-17_19_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_VoiceMessageRecording_null_VoiceMessageRecording-Night-18_20_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-18_19_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-18_19_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 077598db00b..00000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-18_19_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c17bc39b80bbed42378b2df6ad2332b8da78b315a177f56a7eca0a7b595a3036 -size 43943 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-20_21_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-20_21_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..67ee9d1eb8a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Day-20_21_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6bef87fa9b2320571b91093cbe3293b67d9e2180edf036b9bc894da6c6ea6a8d +size 39499 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-18_20_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-18_20_null,NEXUS_5,1.0,en].png deleted file mode 100644 index 08ca64ce835..00000000000 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-18_20_null,NEXUS_5,1.0,en].png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0464a6fc68efe26f6233d993a06024c88ec1936a108f33829e7b474042199fb6 -size 37110 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-20_22_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-20_22_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..f478f3d6058 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.mentions_MentionSpan_null_MentionSpan-Night-20_22_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a03fbda4d8199f29ca78201e6e9ae38d17760332ef7b0ee12585819b3c92dc1 +size 33348 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Day-3_4_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Day-3_4_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..9f1159b500b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Day-3_4_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1eac95cd279b53be30c45f6ee959d617ca70f1cc1097fafe9cdcb58c76a3d72b +size 14190 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Night-3_5_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Night-3_5_null,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..56df65158ae --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_MarkdownTextComposerEdit_null_MarkdownTextComposerEdit-Night-3_5_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d43d29a19b1639f9e569018abd0b3a77d34dac9eb374bff28fa8d673ede272ce +size 13302 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Day-6_7_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Day-7_8_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Day-6_7_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Day-7_8_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Night-6_8_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Night-7_9_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Night-6_8_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_null_TextComposerLinkDialogCreateLinkWithoutText-Night-7_9_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Day-5_6_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Day-6_7_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Day-5_6_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Day-6_7_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Night-5_7_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Night-6_8_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Night-5_7_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogCreateLink_null_TextComposerLinkDialogCreateLink-Night-6_8_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Day-7_8_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Day-8_9_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Day-7_8_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Day-8_9_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Night-7_9_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Night-8_10_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Night-7_9_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerLinkDialogEditLink_null_TextComposerLinkDialogEditLink-Night-8_10_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Day-3_4_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Day-4_5_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Day-3_4_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Day-4_5_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Night-3_5_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Night-4_6_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Night-3_5_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerReply_null_TextComposerReply-Night-4_6_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Day-4_5_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Day-5_6_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Day-4_5_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Day-5_6_null,NEXUS_5,1.0,en].png diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Night-4_6_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Night-5_7_null,NEXUS_5,1.0,en].png similarity index 100% rename from tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Night-4_6_null,NEXUS_5,1.0,en].png rename to tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_TextComposerVoice_null_TextComposerVoice-Night-5_7_null,NEXUS_5,1.0,en].png From 7c45bb7a3edfa1a2afc41365e191dc0909e10eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 May 2024 11:31:42 +0200 Subject: [PATCH 13/20] Add changelog --- changelog.d/2840.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/2840.feature diff --git a/changelog.d/2840.feature b/changelog.d/2840.feature new file mode 100644 index 00000000000..09aa268d4d8 --- /dev/null +++ b/changelog.d/2840.feature @@ -0,0 +1 @@ +Add plain text editor based on Markdown input. From aa5e9428458c165441bd26056a9b265c76dce843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 May 2024 22:41:23 +0200 Subject: [PATCH 14/20] Fix review comments --- .../messages/impl/messagecomposer/MessageComposerPresenter.kt | 3 +-- .../impl/messagecomposer/RichTextEditorStateFactory.kt | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 47a31bf312b..46e1f6f049f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -127,7 +127,7 @@ class MessageComposerPresenter @Inject constructor( // Initially disabled so we don't set focus and text twice var applyFormattingModeChanges by remember { mutableStateOf(false) } - val richTextEditorState = richTextEditorStateFactory.create() + val richTextEditorState = richTextEditorStateFactory.remember() if (isTesting) { richTextEditorState.isReadyToProcessActions = true } @@ -264,7 +264,6 @@ class MessageComposerPresenter @Inject constructor( if (showTextFormatting) { val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) richTextEditorState.setMarkdown(markdown) - // Give some time for the focus of the previous editor to be cleared richTextEditorState.requestFocus() } else { val markdown = richTextEditorState.messageMarkdown diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt index 52fff81c31a..4ce09e800de 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt @@ -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() } } From 9bb714b9537f051a03d6c503a3bb98e3d8164fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 15 May 2024 22:41:51 +0200 Subject: [PATCH 15/20] Fix and some more tests --- .../TestRichTextEditorStateFactory.kt | 2 +- .../components/markdown/MarkdownTextInput.kt | 4 +-- .../markdown/MarkdownTextInputTest.kt | 31 +++++++++++++++++-- .../impl/mentions/MentionSpanProviderTest.kt | 9 ++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt index 17e1c65daf7..921a7331fdc 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt @@ -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) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index a996e779ff8..451eaca53f0 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -118,7 +118,7 @@ private fun Editable.checkSuggestionNeeded(): Suggestion? { // If a mention span already exists we don't need suggestions if (getSpans(startOfWord, startOfWord + 1).isNotEmpty()) return null - return if (firstChar in listOf('@', '#', "/")) { + return if (firstChar in listOf('@', '#', '/')) { var endOfWord = end while (endOfWord < this.length && !this[endOfWord].isWhitespace()) { endOfWord++ @@ -128,7 +128,7 @@ private fun Editable.checkSuggestionNeeded(): Suggestion? { '@' -> SuggestionType.Mention '#' -> SuggestionType.Room '/' -> SuggestionType.Command - else -> return null + else -> error("Unknown suggestion type. This should never happen.") } Suggestion(startOfWord, endOfWord, suggestionType, text) } else { diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index 5c5d49a9db7..8967d4cf6a3 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -106,8 +106,8 @@ class MarkdownTextInputTest { Suggestion(0, 1, SuggestionType.Mention, ""), // Room suggestion Suggestion(0, 1, SuggestionType.Room, ""), - // Slash command suggestion, not supported yet - null, + // Slash command suggestion + Suggestion(0, 1, SuggestionType.Command, ""), ) ) } @@ -125,6 +125,33 @@ class MarkdownTextInputTest { 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) }) diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt index 2b346ceeab6..7245da04c6c 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt @@ -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( From c8e87c194deef3be0b57c6f2d35c3976d5d49935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 16 May 2024 09:02:43 +0200 Subject: [PATCH 16/20] Try fixing Maestro tests --- .maestro/tests/roomList/timeline/messages/text.yaml | 2 +- .maestro/tests/settings/settings.yaml | 2 +- .../textcomposer/components/markdown/MarkdownTextInput.kt | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml index 6767886d8df..f6f70392306 100644 --- a/.maestro/tests/roomList/timeline/messages/text.yaml +++ b/.maestro/tests/roomList/timeline/messages/text.yaml @@ -2,7 +2,7 @@ appId: ${MAESTRO_APP_ID} --- - takeScreenshot: build/maestro/510-Timeline - tapOn: - id: "rich_text_editor" + id: "plain_text_editor" - inputText: "Hello world!" - tapOn: "Send" - hideKeyboard diff --git a/.maestro/tests/settings/settings.yaml b/.maestro/tests/settings/settings.yaml index c77d118a3b1..15181a458b3 100644 --- a/.maestro/tests/settings/settings.yaml +++ b/.maestro/tests/settings/settings.yaml @@ -34,7 +34,7 @@ appId: ${MAESTRO_APP_ID} - tapOn: text: "Advanced settings" -- assertVisible: "Rich text editor" +- assertVisible: "View source" - back - tapOn: diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index 451eaca53f0..91136e9a662 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -31,6 +31,7 @@ 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.testtags.testTag import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState @@ -52,7 +53,8 @@ fun MarkdownTextInput( AndroidView( modifier = Modifier .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(TestTags.plainTextEditor), factory = { context -> MarkdownEditText(context).apply { tag = TestTags.plainTextEditor.value // Needed for UI tests From b73a63b95595f3db486c38111595c81bcd30b5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 16 May 2024 11:06:18 +0200 Subject: [PATCH 17/20] Fix sending messages not clearing the right text editor state --- .../messagecomposer/MessageComposerPresenter.kt | 13 ++++++++----- .../textcomposer/MessageComposerPresenterTest.kt | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 46e1f6f049f..2c4e180bd73 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -30,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 @@ -250,11 +251,13 @@ class MessageComposerPresenter @Inject constructor( } } - val textEditorState = if (showTextFormatting) { - TextEditorState.Rich(richTextEditorState) - } else { - TextEditorState.Markdown(markdownTextEditorState) - } + val textEditorState by rememberUpdatedState( + if (showTextFormatting) { + TextEditorState.Rich(richTextEditorState) + } else { + TextEditorState.Markdown(markdownTextEditorState) + } + ) LaunchedEffect(showTextFormatting) { if (!applyFormattingModeChanges) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 42848bd3088..f0bf4c42efc 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -764,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() } From 93e1590f19f2f75017fd600071d16d978ca46290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 16 May 2024 11:08:39 +0200 Subject: [PATCH 18/20] Rename `ImmutableCharSequence`, move placeholder generation --- .../libraries/textcomposer/TextComposer.kt | 15 +++++---------- ...tableCharSequence.kt => StableCharSequence.kt} | 2 +- .../textcomposer/model/MarkdownTextEditorState.kt | 4 ++-- 3 files changed, 8 insertions(+), 13 deletions(-) rename libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/{ImmutableCharSequence.kt => StableCharSequence.kt} (96%) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index f0055ac78d4..a647a6ac16d 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -146,13 +146,13 @@ fun TextComposer( } } + 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 -> { - val placeholder = if (composerMode.inThread) { - stringResource(id = CommonStrings.action_reply_in_thread) - } else { - stringResource(id = R.string.rich_text_editor_composer_placeholder) - } remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { @Composable { val mentionSpanProvider = rememberMentionSpanProvider( @@ -175,11 +175,6 @@ fun TextComposer( } } is TextEditorState.Markdown -> { - val placeholder = if (composerMode.inThread) { - stringResource(id = CommonStrings.action_reply_in_thread) - } else { - stringResource(id = R.string.rich_text_editor_composer_placeholder) - } @Composable { val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) TextInputBox( diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt similarity index 96% rename from libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt index cc93af4b3b7..5491f9ccf47 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/ImmutableCharSequence.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.setValue import io.element.android.libraries.core.extensions.orEmpty @Stable -class ImmutableCharSequence(initialText: CharSequence = "") { +class StableCharSequence(initialText: CharSequence = "") { private var value by mutableStateOf(SpannableString(initialText)) private var needsDisplaying by mutableStateOf(false) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt index ee15959424a..7cda8f421cf 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -29,7 +29,7 @@ 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.ImmutableCharSequence +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 @@ -39,7 +39,7 @@ class MarkdownTextEditorState( initialText: String?, initialFocus: Boolean, ) { - var text by mutableStateOf(ImmutableCharSequence(initialText ?: "")) + var text by mutableStateOf(StableCharSequence(initialText ?: "")) var selection by mutableStateOf(0..0) var hasFocus by mutableStateOf(initialFocus) var requestFocusAction by mutableStateOf({}) From 1e079d3f82f37c5d16f2e280f4fac80ad6189528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 16 May 2024 12:06:38 +0200 Subject: [PATCH 19/20] Fix maestro tests --- .maestro/tests/roomList/timeline/messages/text.yaml | 2 +- .../features/messages/impl/MessagesStateProvider.kt | 7 ++++--- .../impl/messagecomposer/MessageComposerStateProvider.kt | 5 ++--- .../io/element/android/libraries/testtags/TestTags.kt | 4 ++-- .../android/libraries/textcomposer/TextComposer.kt | 8 ++++++-- .../textcomposer/components/markdown/MarkdownTextInput.kt | 4 +--- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml index f6f70392306..cae5e310c02 100644 --- a/.maestro/tests/roomList/timeline/messages/text.yaml +++ b/.maestro/tests/roomList/timeline/messages/text.yaml @@ -2,7 +2,7 @@ appId: ${MAESTRO_APP_ID} --- - takeScreenshot: build/maestro/510-Timeline - tapOn: - id: "plain_text_editor" + id: "text_editor" - inputText: "Hello world!" - tapOn: "Send" - hideKeyboard diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index acd7e86d58a..fd00eba04be 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -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( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index a6b147055e1..7ec47f4ef54 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -24,7 +24,6 @@ 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.libraries.textcomposer.model.TextEditorState -import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -36,7 +35,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider = persistentListOf(), ) = MessageComposerState( - textEditorState = TextEditorState.Rich(richTextEditorState), + textEditorState = textEditorState, permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData = TODO() }, diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 7a504023c71..3281f282225 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -70,9 +70,9 @@ 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. diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index a647a6ac16d..3ff2520c35b 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -182,6 +182,7 @@ fun TextComposer( onResetComposerMode = onResetComposerMode, placeholder = placeholder, showPlaceholder = { state.state.text.value().isEmpty() }, + subcomposing = subcomposing, ) { MarkdownTextInput( state = state.state, @@ -425,6 +426,7 @@ private fun TextInputBox( onResetComposerMode: () -> Unit, placeholder: String, showPlaceholder: () -> Boolean, + subcomposing: Boolean, textInput: @Composable () -> Unit, ) { val bgColor = ElementTheme.colors.bgSubtleSecondary @@ -446,7 +448,8 @@ private fun TextInputBox( 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 @@ -483,7 +486,8 @@ private fun TextInput( composerMode = composerMode, onResetComposerMode = onResetComposerMode, placeholder = placeholder, - showPlaceholder = { state.messageHtml.isEmpty() } + showPlaceholder = { state.messageHtml.isEmpty() }, + subcomposing = subcomposing, ) { RichTextEditor( state = state, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index 91136e9a662..451eaca53f0 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -31,7 +31,6 @@ 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.testtags.testTag import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState @@ -53,8 +52,7 @@ fun MarkdownTextInput( AndroidView( modifier = Modifier .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth() - .testTag(TestTags.plainTextEditor), + .fillMaxWidth(), factory = { context -> MarkdownEditText(context).apply { tag = TestTags.plainTextEditor.value // Needed for UI tests From 4864da3de8342f22c2f5c9d7e0f105d8c6f991ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 21 May 2024 13:17:23 +0200 Subject: [PATCH 20/20] Bump RTE library to released `v2.37.3` --- gradle/libs.versions.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f152c5a58f6..870bd6862cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,8 +43,7 @@ serialization_json = "1.6.3" showkase = "1.0.2" appyx = "1.4.0" sqldelight = "2.0.2" -# TODO: revert snapshot -wysiwyg = "2.37.2-markdown-SNAPSHOT" +wysiwyg = "2.37.3" telephoto = "0.11.2" # DI