diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml index 6767886d8df..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: "rich_text_editor" + id: "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/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/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. 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/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/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 597fd4b8c9d..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 @@ -362,7 +362,7 @@ private fun MessagesViewContent( // Any state change that should trigger a height size should be added to the list of remembered values here. val sheetResizeContentKey = remember { mutableIntStateOf(0) } LaunchedEffect( - state.composerState.richTextEditorState.lineCount, + state.composerState.textEditorState.lineCount, state.composerState.showTextFormatting, ) { sheetResizeContentKey.intValue = Random.nextInt() @@ -439,7 +439,6 @@ private fun MessagesViewComposerBottomSheetContents( state = state.composerState, voiceMessageState = state.voiceMessageComposerState, subcomposing = subcomposing, - enableTextFormatting = state.enableTextFormatting, enableVoiceMessages = state.enableVoiceMessages, modifier = Modifier.fillMaxWidth(), ) 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..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 @@ -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.AtRoom -> "@room" + is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value } } ) { @@ -84,18 +85,18 @@ fun MentionSuggestionsPickerView( @Composable private fun RoomMemberSuggestionItemView( - memberSuggestion: MentionSuggestion, + memberSuggestion: ResolvedMentionSuggestion, roomId: String, roomName: String?, roomAvatar: AvatarData?, - onSuggestionSelected: (MentionSuggestion) -> Unit, + onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit, modifier: Modifier = Modifier, ) { Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { val avatarSize = AvatarSize.TimelineRoom val avatarData = when (memberSuggestion) { - is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) - is MentionSuggestion.Member -> AvatarData( + is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) + is ResolvedMentionSuggestion.Member -> AvatarData( memberSuggestion.roomMember.userId.value, memberSuggestion.roomMember.displayName, memberSuggestion.roomMember.avatarUrl, @@ -103,13 +104,13 @@ private fun RoomMemberSuggestionItemView( ) } val title = when (memberSuggestion) { - is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title) - is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName + is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) + is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName } val subtitle = when (memberSuggestion) { - is MentionSuggestion.Room -> "@room" - is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value + is ResolvedMentionSuggestion.AtRoom -> "@room" + is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value } Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) @@ -159,9 +160,9 @@ internal fun MentionSuggestionsPickerViewPreview() { roomName = "Room", roomAvatarData = null, memberSuggestions = persistentListOf( - MentionSuggestion.Room, - MentionSuggestion.Member(roomMember), - MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), + ResolvedMentionSuggestion.AtRoom, + ResolvedMentionSuggestion.Member(roomMember), + ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), ), onSuggestionSelected = {} ) 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..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 @@ -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.AtRoom) + 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..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 @@ -18,15 +18,14 @@ package io.element.android.features.messages.impl.messagecomposer import android.net.Uri import androidx.compose.runtime.Immutable -import io.element.android.features.messages.impl.mentions.MentionSuggestion -import io.element.android.libraries.textcomposer.model.Message +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion @Immutable sealed interface MessageComposerEvents { data object ToggleFullScreenState : MessageComposerEvents - data class SendMessage(val message: Message) : MessageComposerEvents + data object SendMessage : MessageComposerEvents data class SendUri(val uri: Uri) : MessageComposerEvents data object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents @@ -45,5 +44,5 @@ sealed interface MessageComposerEvents { data class Error(val error: Throwable) : MessageComposerEvents data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents - data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents + data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents } 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..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 @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer import android.Manifest import android.annotation.SuppressLint import android.net.Uri +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.media3.common.MimeTypes @@ -36,7 +38,6 @@ import androidx.media3.common.util.UnstableApi import im.vector.app.features.analytics.plan.Composer import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.preferences.api.store.SessionPreferencesStore @@ -59,17 +60,21 @@ import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine @@ -108,12 +113,27 @@ class MessageComposerPresenter @Inject constructor( private val suggestionSearchTrigger = MutableStateFlow(null) + // Used to disable some UI related elements in tests + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var isTesting: Boolean = false + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var showTextFormatting: Boolean by mutableStateOf(false) + @OptIn(FlowPreview::class) @SuppressLint("UnsafeOptInUsageError") @Composable override fun present(): MessageComposerState { val localCoroutineScope = rememberCoroutineScope() + // Initially disabled so we don't set focus and text twice + var applyFormattingModeChanges by remember { mutableStateOf(false) } + val richTextEditorState = richTextEditorStateFactory.remember() + if (isTesting) { + richTextEditorState.isReadyToProcessActions = true + } + val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null, initialFocus = false) } + var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) @@ -149,18 +169,20 @@ class MessageComposerPresenter @Inject constructor( val isFullScreen = rememberSaveable { mutableStateOf(false) } - val richTextEditorState = richTextEditorStateFactory.create() val ongoingSendAttachmentJob = remember { mutableStateOf(null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } - var showTextFormatting: Boolean by remember { mutableStateOf(false) } val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) LaunchedEffect(messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) { is MessageComposerMode.Edit -> - richTextEditorState.setHtml(modeValue.defaultContent) + if (showTextFormatting) { + richTextEditorState.setHtml(modeValue.defaultContent) + } else { + markdownTextEditorState.text.update(modeValue.defaultContent, true) + } else -> Unit } } @@ -188,7 +210,7 @@ class MessageComposerPresenter @Inject constructor( } } - val memberSuggestions = remember { mutableStateListOf() } + val memberSuggestions = remember { mutableStateListOf() } LaunchedEffect(isMentionsEnabled) { if (!isMentionsEnabled) return@LaunchedEffect val currentUserId = currentSessionIdHolder.current @@ -229,22 +251,69 @@ class MessageComposerPresenter @Inject constructor( } } + val textEditorState by rememberUpdatedState( + if (showTextFormatting) { + TextEditorState.Rich(richTextEditorState) + } else { + TextEditorState.Markdown(markdownTextEditorState) + } + ) + + LaunchedEffect(showTextFormatting) { + if (!applyFormattingModeChanges) { + applyFormattingModeChanges = true + return@LaunchedEffect + } + if (showTextFormatting) { + val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + richTextEditorState.setMarkdown(markdown) + richTextEditorState.requestFocus() + } else { + val markdown = richTextEditorState.messageMarkdown + markdownTextEditorState.text.update(markdown, true) + // Give some time for the focus of the previous editor to be cleared + delay(100) + markdownTextEditorState.requestFocusAction() + } + } + + val mentionSpanProvider = if (isTesting) { + null + } else { + rememberMentionSpanProvider( + currentUserId = room.sessionId, + permalinkParser = permalinkParser, + ) + } + fun handleEvents(event: MessageComposerEvents) { when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.CloseSpecialMode -> { if (messageComposerContext.composerMode is MessageComposerMode.Edit) { localCoroutineScope.launch { - richTextEditorState.setHtml("") + textEditorState.reset() } } messageComposerContext.composerMode = MessageComposerMode.Normal } - is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( - message = event.message, - updateComposerMode = { messageComposerContext.composerMode = it }, - richTextEditorState = richTextEditorState, - ) + is MessageComposerEvents.SendMessage -> { + val html = if (showTextFormatting) { + richTextEditorState.messageHtml + } else { + null + } + val markdown = if (showTextFormatting) { + richTextEditorState.messageMarkdown + } else { + markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + } + appCoroutineScope.sendMessage( + message = Message(html = html, markdown = markdown), + updateComposerMode = { messageComposerContext.composerMode = it }, + textEditorState = textEditorState, + ) + } is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment( attachment = Attachment.Media( localMedia = localMediaFactory.createFromUri( @@ -335,15 +404,26 @@ class MessageComposerPresenter @Inject constructor( } is MessageComposerEvents.InsertMention -> { localCoroutineScope.launch { - when (val mention = event.mention) { - is MentionSuggestion.Room -> { - richTextEditorState.insertAtRoomMentionAtSuggestion() + if (showTextFormatting) { + when (val mention = event.mention) { + is ResolvedMentionSuggestion.AtRoom -> { + richTextEditorState.insertAtRoomMentionAtSuggestion() + } + is ResolvedMentionSuggestion.Member -> { + val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch + richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } } - is MentionSuggestion.Member -> { - val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch - richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } else if (markdownTextEditorState.currentMentionSuggestion != null) { + mentionSpanProvider?.let { + markdownTextEditorState.insertMention( + mention = event.mention, + mentionSpanProvider = it, + permalinkBuilder = permalinkBuilder, + ) } + suggestionSearchTrigger.value = null } } } @@ -351,7 +431,7 @@ class MessageComposerPresenter @Inject constructor( } return MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, permalinkParser = permalinkParser, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, @@ -369,21 +449,26 @@ class MessageComposerPresenter @Inject constructor( private fun CoroutineScope.sendMessage( message: Message, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, - richTextEditorState: RichTextEditorState, + textEditorState: TextEditorState, ) = launch { val capturedMode = messageComposerContext.composerMode - val mentions = richTextEditorState.mentionsState?.let { state -> - buildList { - if (state.hasAtRoomMention) { - add(Mention.AtRoom) - } - for (userId in state.userIds) { - add(Mention.User(UserId(userId))) - } + val mentions = when (textEditorState) { + is TextEditorState.Rich -> { + textEditorState.richTextEditorState.mentionsState?.let { state -> + buildList { + if (state.hasAtRoomMention) { + add(Mention.AtRoom) + } + for (userId in state.userIds) { + add(Mention.User(UserId(userId))) + } + } + }.orEmpty() } - }.orEmpty() + is TextEditorState.Markdown -> textEditorState.state.getMentions() + } // Reset composer right away - richTextEditorState.setHtml("") + textEditorState.reset() updateComposerMode(MessageComposerMode.Normal) when (capturedMode) { is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions) 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..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,16 +19,16 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.ImmutableList @Stable data class MessageComposerState( - val richTextEditorState: RichTextEditorState, + val textEditorState: TextEditorState, val permalinkParser: PermalinkParser, val isFullScreen: Boolean, val mode: MessageComposerMode, @@ -37,12 +37,10 @@ data class MessageComposerState( val canShareLocation: Boolean, val canCreatePoll: Boolean, val attachmentsState: AttachmentsState, - val memberSuggestions: ImmutableList, + val memberSuggestions: ImmutableList, val currentUserId: UserId, val eventSink: (MessageComposerEvents) -> Unit, -) { - val hasFocus: Boolean = richTextEditorState.hasFocus -} +) @Immutable sealed interface AttachmentsState { 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..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 @@ -17,13 +17,13 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.aRichTextEditorState +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -35,7 +35,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider = persistentListOf(), + memberSuggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, 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..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,13 +43,12 @@ internal fun MessageComposerView( state: MessageComposerState, voiceMessageState: VoiceMessageComposerState, subcomposing: Boolean, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, modifier: Modifier = Modifier, ) { val view = LocalView.current - fun sendMessage(message: Message) { - state.eventSink(MessageComposerEvents.SendMessage(message)) + fun sendMessage() { + state.eventSink(MessageComposerEvents.SendMessage) } fun sendUri(uri: Uri) { @@ -85,7 +83,7 @@ internal fun MessageComposerView( val coroutineScope = rememberCoroutineScope() fun onRequestFocus() { coroutineScope.launch { - state.richTextEditorState.requestFocus() + state.textEditorState.requestFocus() } } @@ -107,7 +105,7 @@ internal fun MessageComposerView( TextComposer( modifier = modifier, - state = state.richTextEditorState, + state = state.textEditorState, voiceMessageState = voiceMessageState.voiceMessageState, permalinkParser = state.permalinkParser, subcomposing = subcomposing, @@ -118,7 +116,6 @@ internal fun MessageComposerView( onResetComposerMode = ::onCloseSpecialMode, onAddAttachment = ::onAddAttachment, onDismissTextFormatting = ::onDismissTextFormatting, - enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, onVoiceRecorderEvent = onVoiceRecorderEvent, onVoicePlayerEvent = onVoicePlayerEvent, @@ -142,7 +139,6 @@ internal fun MessageComposerViewPreview( modifier = Modifier.height(IntrinsicSize.Min), state = state, voiceMessageState = aVoiceMessageComposerState(), - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) @@ -150,7 +146,6 @@ internal fun MessageComposerViewPreview( modifier = Modifier.height(200.dp), state = state, voiceMessageState = aVoiceMessageComposerState(), - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) @@ -167,7 +162,6 @@ internal fun MessageComposerViewVoicePreview( modifier = Modifier.height(IntrinsicSize.Min), state = aMessageComposerState(), voiceMessageState = state, - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) 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() } } 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 0fe401584bc..14eb3616ad5 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 @@ -538,7 +538,7 @@ class MessagesPresenterTest { // Initially the composer doesn't have focus, so we don't show the alert assertThat(initialState.showReinvitePrompt).isFalse() // When the input field is focused we show the alert - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state -> state.showReinvitePrompt }.last() @@ -561,7 +561,7 @@ class MessagesPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -576,7 +576,7 @@ class MessagesPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -781,7 +781,7 @@ class MessagesPresenterTest { ): MessagesPresenter { val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) - val appPreferencesStore = InMemoryAppPreferencesStore(isRichTextEditorEnabled = true) + val appPreferencesStore = InMemoryAppPreferencesStore() val sessionPreferencesStore = InMemorySessionPreferencesStore() val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, @@ -800,7 +800,10 @@ class MessagesPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), timelineController = TimelineController(matrixRoom), - ) + ).apply { + showTextFormatting = true + isTesting = true + } val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( this, FakeVoiceRecorder(), @@ -853,7 +856,6 @@ class MessagesPresenterTest { messageSummaryFormatter = FakeMessageSummaryFormatter(), navigator = navigator, clipboardHelper = clipboardHelper, - appPreferencesStore = appPreferencesStore, featureFlagsService = FakeFeatureFlagService(), buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, 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..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 @@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -77,10 +76,11 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore -import io.element.android.libraries.textcomposer.model.Message +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.any @@ -127,7 +127,7 @@ class MessageComposerPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.isFullScreen).isFalse() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() @@ -158,10 +158,10 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml(A_MESSAGE) - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - initialState.richTextEditorState.setHtml("") - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + initialState.textEditorState.setHtml(A_MESSAGE) + assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + initialState.textEditorState.setHtml("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") } } @@ -170,7 +170,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { var state = awaitFirstItem() val mode = anEditMode() @@ -178,11 +178,11 @@ class MessageComposerPresenterTest { state = awaitItem() assertThat(state.mode).isEqualTo(mode) state = awaitItem() - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) state = backToNormalMode(state, skipCount = 1) // The message that was being edited is cleared - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") } } @@ -197,7 +197,7 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @@ -213,11 +213,11 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - state.richTextEditorState.setHtml(A_REPLY) + state.textEditorState.setHtml(A_REPLY) state = backToNormalMode(state) // The message typed while replying is not cleared - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) } } @@ -232,25 +232,54 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @Test - fun `present - send message`() = runTest { + fun `present - send message with rich text enabled`() = runTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml(A_MESSAGE) + initialState.textEditorState.setHtml(A_MESSAGE) val withMessageState = awaitItem() - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + waitForPredicate { analyticsService.capturedEvents.size == 1 } + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - send message with plain text enabled`() = runTest { + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("") }) + val presenter = createPresenter(this, isRichTextEditorEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder) + remember(state, messageMarkdown) { state } + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setMarkdown(A_MESSAGE) + val withMessageState = awaitItem() + assertThat(withMessageState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isNull() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("") waitForPredicate { analyticsService.capturedEvents.size == 1 } assertThat(analyticsService.capturedEvents).containsExactly( Composer( @@ -278,23 +307,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = anEditMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) skipItems(1) val withMessageState = awaitItem() assertThat(withMessageState.mode).isEqualTo(mode) - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -328,23 +357,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID) initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) skipItems(1) val withMessageState = awaitItem() assertThat(withMessageState.mode).isEqualTo(mode) - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -380,17 +409,17 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = aReplyMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) val state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") - state.richTextEditorState.setHtml(A_REPLY) - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY) - state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage())) + assertThat(state.textEditorState.messageHtml()).isEqualTo("") + state.textEditorState.setHtml(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) + state.eventSink.invoke(MessageComposerEvents.SendMessage) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -725,7 +754,7 @@ class MessageComposerPresenterTest { @Test fun `present - ToggleTextFormatting toggles text formatting`() = runTest { - val presenter = createPresenter(this) + val presenter = createPresenter(this, isRichTextEditorEnabled = false) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -735,11 +764,12 @@ class MessageComposerPresenterTest { val composerOptions = awaitItem() assertThat(composerOptions.showAttachmentSourcePicker).isTrue() composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true)) - awaitItem() // composer options closed + skipItems(2) // composer options closed val showTextFormatting = awaitItem() assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse() assertThat(showTextFormatting.showTextFormatting).isTrue() showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false)) + skipItems(1) val finished = awaitItem() assertThat(finished.showTextFormatting).isFalse() } @@ -781,19 +811,19 @@ class MessageComposerPresenterTest { // An empty suggestion returns the room and joined members that are not the current user initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Room, MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) // A suggestion containing a part of "room" will also return the room mention initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Room) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.AtRoom) // A non-empty suggestion will return those joined members whose user id matches it initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(bob)) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(bob)) // A non-empty suggestion will return those joined members whose display name matches it initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(david)) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(david)) // If the suggestion isn't a mention, no suggestions are returned initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) @@ -803,7 +833,7 @@ class MessageComposerPresenterTest { room.givenCanTriggerRoomNotification(Result.success(false)) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) // If room is a DM, `RoomMemberSuggestion.Room` is not returned room.givenCanTriggerRoomNotification(Result.success(true)) @@ -844,7 +874,7 @@ class MessageComposerPresenterTest { initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) skipItems(1) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) } } @@ -862,10 +892,10 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml("Hey @bo") - initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) + initialState.textEditorState.setHtml("Hey @bo") + initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) - assertThat(initialState.richTextEditorState.messageHtml) + assertThat(initialState.textEditorState.messageHtml()) .isEqualTo("Hey ${A_USER_ID_2.value}") } } @@ -892,14 +922,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on message sent val mentionUser1 = listOf(A_USER_ID.value) - initialState.richTextEditorState.mentionsState = MentionsState( + (initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser1, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.richTextEditorState.setHtml(A_MESSAGE) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() @@ -908,14 +938,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on reply sent initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode())) val mentionUser2 = listOf(A_USER_ID_2.value) - awaitItem().richTextEditorState.mentionsState = MentionsState( + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser2, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() assert(replyMessageLambda) @@ -926,14 +956,14 @@ class MessageComposerPresenterTest { skipItems(1) initialState.eventSink(MessageComposerEvents.SetMode(anEditMode())) val mentionUser3 = listOf(A_USER_ID_3.value) - awaitItem().richTextEditorState.mentionsState = MentionsState( + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser3, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() assert(editMessageLambda) @@ -949,7 +979,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri"))) @@ -1007,7 +1037,8 @@ class MessageComposerPresenterTest { mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), - permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder() + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + isRichTextEditorEnabled: Boolean = true, ) = MessageComposerPresenter( coroutineScope, room, @@ -1025,7 +1056,10 @@ class MessageComposerPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = permalinkBuilder, timelineController = TimelineController(room), - ) + ).apply { + isTesting = true + showTextFormatting = isRichTextEditorEnabled + } private suspend fun ReceiveTurbine.awaitFirstItem(): T { // Skip 2 item if Mentions feature is enabled, else 1 @@ -1043,7 +1077,10 @@ fun anEditMode( fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) -private fun String.toMessage() = Message( - html = this, - markdown = this, -) +private suspend fun TextEditorState.setHtml(html: String) { + (this as? TextEditorState.Rich)?.richTextEditorState?.setHtml(html) ?: error("TextEditorState is not Rich") +} + +private fun TextEditorState.setMarkdown(markdown: String) { + (this as? TextEditorState.Markdown)?.state?.text?.update(markdown, needsDisplaying = false) ?: error("TextEditorState is not Markdown") +} 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/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 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/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt index 4e78978873c..8bdea347273 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt @@ -19,9 +19,6 @@ package io.element.android.features.preferences.api.store import kotlinx.coroutines.flow.Flow interface AppPreferencesStore { - suspend fun setRichTextEditorEnabled(enabled: Boolean) - fun isRichTextEditorEnabledFlow(): Flow - 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..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") @@ -48,19 +46,6 @@ class DefaultAppPreferencesStore @Inject constructor( ) : AppPreferencesStore { private val store = context.dataStore - override suspend fun setRichTextEditorEnabled(enabled: Boolean) { - store.edit { prefs -> - prefs[richTextEditorKey] = enabled - } - } - - override fun isRichTextEditorEnabledFlow(): Flow { - 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/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 5a74fa42f08..3bad8f648da 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 @@ -75,9 +75,14 @@ object TestTags { val welcomeScreenTitle = TestTag("welcome_screen-title") /** - * RichTextEditor. + * TextEditor. */ - val richTextEditor = TestTag("rich_text_editor") + val textEditor = TestTag("text_editor") + + /** + * EditText inside the MarkdownTextInput. + */ + val plainTextEditor = TestTag("plain_text_editor") /** * Message bubble. 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/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 e31183bd143..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 @@ -19,8 +19,6 @@ package io.element.android.libraries.textcomposer import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,30 +32,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.media.createFakeWaveform import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.TransactionId @@ -66,7 +56,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH -import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.testtags.TestTags @@ -79,11 +68,13 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteBu import io.element.android.libraries.textcomposer.components.VoiceMessagePreview import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton import io.element.android.libraries.textcomposer.components.VoiceMessageRecording +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider -import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState @@ -98,15 +89,14 @@ import kotlin.time.Duration.Companion.seconds @Composable fun TextComposer( - state: RichTextEditorState, + state: TextEditorState, voiceMessageState: VoiceMessageState, permalinkParser: PermalinkParser, composerMode: MessageComposerMode, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, currentUserId: UserId, onRequestFocus: () -> Unit, - onSendMessage: (Message) -> Unit, + onSendMessage: () -> Unit, onResetComposerMode: () -> Unit, onAddAttachment: () -> Unit, onDismissTextFormatting: () -> Unit, @@ -122,9 +112,12 @@ fun TextComposer( showTextFormatting: Boolean = false, subcomposing: Boolean = false, ) { + val markdown = when (state) { + is TextEditorState.Markdown -> state.state.text.value() + is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown + } val onSendClicked = { - val html = if (enableTextFormatting) state.messageHtml else null - onSendMessage(Message(html = html, markdown = state.messageMarkdown)) + onSendMessage() } val onPlayVoiceMessageClicked = { @@ -153,32 +146,57 @@ fun TextComposer( } } - val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) { - @Composable { - val mentionSpanProvider = rememberMentionSpanProvider( - currentUserId = currentUserId, - permalinkParser = permalinkParser, - ) - TextInput( - state = state, - subcomposing = subcomposing, - placeholder = if (composerMode.inThread) { - stringResource(id = CommonStrings.action_reply_in_thread) - } else { - stringResource(id = R.string.rich_text_editor_composer_placeholder) - }, - composerMode = composerMode, - onResetComposerMode = onResetComposerMode, - resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, - resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, - onError = onError, - onTyping = onTyping, - onRichContentSelected = onRichContentSelected, - ) + val placeholder = if (composerMode.inThread) { + stringResource(id = CommonStrings.action_reply_in_thread) + } else { + stringResource(id = R.string.rich_text_editor_composer_placeholder) + } + val textInput: @Composable () -> Unit = when (state) { + is TextEditorState.Rich -> { + remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { + @Composable { + val mentionSpanProvider = rememberMentionSpanProvider( + currentUserId = currentUserId, + permalinkParser = permalinkParser, + ) + TextInput( + state = state.richTextEditorState, + subcomposing = subcomposing, + placeholder = placeholder, + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, + resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, + onError = onError, + onTyping = onTyping, + onRichContentSelected = onRichContentSelected, + ) + } + } + } + is TextEditorState.Markdown -> { + @Composable { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + placeholder = placeholder, + showPlaceholder = { state.state.text.value().isEmpty() }, + subcomposing = subcomposing, + ) { + MarkdownTextInput( + state = state.state, + subcomposing = subcomposing, + onTyping = onTyping, + onSuggestionReceived = onSuggestionReceived, + richTextEditorStyle = style, + ) + } + } } } - val canSendMessage by remember { derivedStateOf { state.messageMarkdown.isNotBlank() } } + val canSendMessage = markdown.isNotBlank() val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, @@ -205,7 +223,9 @@ fun TextComposer( ) } - val textFormattingOptions = @Composable { TextFormatting(state = state) } + val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { + @Composable { TextFormatting(state = it.richTextEditorState) } + } val sendOrRecordButton = when { enableVoiceMessages && !canSendMessage -> @@ -217,8 +237,7 @@ fun TextComposer( false -> sendVoiceButton } } - else -> - sendButton + else -> sendButton } val voiceRecording = @Composable { @@ -251,7 +270,7 @@ fun TextComposer( } } - if (showTextFormatting) { + if (showTextFormatting && textFormattingOptions != null) { TextFormattingLayout( modifier = layoutModifier, textInput = textInput, @@ -282,14 +301,16 @@ fun TextComposer( SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } } - val menuAction = state.menuAction val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived) - LaunchedEffect(menuAction) { - if (menuAction is MenuAction.Suggestion) { - val suggestion = Suggestion(menuAction.suggestionPattern) - latestOnSuggestionReceived(suggestion) - } else { - latestOnSuggestionReceived(null) + if (state is TextEditorState.Rich) { + val menuAction = state.richTextEditorState.menuAction + LaunchedEffect(menuAction) { + if (menuAction is MenuAction.Suggestion) { + val suggestion = Suggestion(menuAction.suggestionPattern) + latestOnSuggestionReceived(suggestion) + } else { + latestOnSuggestionReceived(null) + } } } } @@ -400,17 +421,13 @@ private fun TextFormattingLayout( } @Composable -private fun TextInput( - state: RichTextEditorState, - subcomposing: Boolean, - placeholder: String, +private fun TextInputBox( composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, - resolveRoomMentionDisplay: () -> TextDisplay, - resolveMentionDisplay: (text: String, url: String) -> TextDisplay, - onError: (Throwable) -> Unit, - onTyping: (Boolean) -> Unit, - onRichContentSelected: ((Uri) -> Unit)?, + placeholder: String, + showPlaceholder: () -> Boolean, + subcomposing: Boolean, + textInput: @Composable () -> Unit, ) { val bgColor = ElementTheme.colors.bgSubtleSecondary val borderColor = ElementTheme.colors.borderDisabled @@ -431,11 +448,12 @@ private fun TextInput( Box( modifier = Modifier .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) - .testTag(TestTags.richTextEditor), + // Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail + .then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier), contentAlignment = Alignment.CenterStart, ) { // Placeholder - if (state.messageHtml.isEmpty()) { + if (showPlaceholder()) { Text( placeholder, style = defaultTypography.copy( @@ -446,155 +464,45 @@ private fun TextInput( ) } - RichTextEditor( - state = state, - // Disable most of the editor functionality if it's just being measured for a subcomposition. - // This prevents it gaining focus and mutating the state. - registerStateUpdates = !subcomposing, - modifier = Modifier - .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), - style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), - resolveMentionDisplay = resolveMentionDisplay, - resolveRoomMentionDisplay = resolveRoomMentionDisplay, - onError = onError, - onRichContentSelected = onRichContentSelected, - onTyping = onTyping, - ) + textInput() } } } @Composable -private fun ComposerModeView( +private fun TextInput( + state: RichTextEditorState, + subcomposing: Boolean, + placeholder: String, composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, + resolveRoomMentionDisplay: () -> TextDisplay, + resolveMentionDisplay: (text: String, url: String) -> TextDisplay, + onError: (Throwable) -> Unit, + onTyping: (Boolean) -> Unit, + onRichContentSelected: ((Uri) -> Unit)?, ) { - when (composerMode) { - is MessageComposerMode.Edit -> { - EditingModeView(onResetComposerMode = onResetComposerMode) - } - is MessageComposerMode.Reply -> { - ReplyToModeView( - modifier = Modifier.padding(8.dp), - senderName = composerMode.senderName, - text = composerMode.defaultContent, - attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, - onResetComposerMode = onResetComposerMode, - ) - } - else -> Unit - } -} - -@Composable -private fun EditingModeView( - onResetComposerMode: () -> Unit, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp) - ) { - 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) + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + placeholder = placeholder, + showPlaceholder = { state.messageHtml.isEmpty() }, + subcomposing = subcomposing, ) { - 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, + 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(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp) - .size(16.dp) - .clickable( - enabled = true, - onClick = onResetComposerMode, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ), + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), + resolveMentionDisplay = resolveMentionDisplay, + resolveRoomMentionDisplay = resolveRoomMentionDisplay, + onError = onError, + onRichContentSelected = onRichContentSelected, + onTyping = onTyping, ) } } @@ -606,43 +514,41 @@ internal fun TextComposerSimplePreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(initialText = "", initialFocus = true), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost"), ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState( - initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", - initialFocus = true + TextEditorState.Markdown( + aMarkdownTextEditorState( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + initialFocus = true + ) ), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message without focus"), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus", initialFocus = false)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -656,33 +562,32 @@ internal fun TextComposerSimplePreview() = ElementPreview { internal fun TextComposerFormattingPreview() = ElementPreview { PreviewColumn(items = persistentListOf({ ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState( - initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + TextEditorState.Rich( + aRichTextEditorState( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + ) ), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -694,10 +599,23 @@ internal fun TextComposerFormattingPreview() = ElementPreview { internal fun TextComposerEditPreview() = ElementPreview { PreviewColumn(items = persistentListOf({ ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), + enableVoiceMessages = true, + currentUserId = UserId("@alice:localhost") + ) + })) +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextComposerEditPreview() = ElementPreview { + PreviewColumn(items = persistentListOf({ + ATextComposer( + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -711,7 +629,7 @@ internal fun TextComposerReplyPreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -722,14 +640,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { "With several lines\n" + "To preview larger textfields and long lines with overflow" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = true, @@ -740,14 +657,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { "With several lines\n" + "To preview larger textfields and long lines with overflow" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = true, @@ -761,14 +677,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "image.jpg" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -782,14 +697,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "video.mp4" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -803,14 +717,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "logs.txt" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -824,7 +737,6 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "Shared location" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -840,10 +752,9 @@ internal fun TextComposerVoicePreview() = ElementPreview { fun VoicePreview( voiceMessageState: VoiceMessageState ) = ATextComposer( - aRichTextEditorState(initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialFocus = true)), voiceMessageState = voiceMessageState, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -902,23 +813,21 @@ private fun PreviewColumn( @Composable private fun ATextComposer( - richTextEditorState: RichTextEditorState, + state: TextEditorState, voiceMessageState: VoiceMessageState, composerMode: MessageComposerMode, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, currentUserId: UserId, showTextFormatting: Boolean = false, ) { TextComposer( - state = richTextEditorState, + state = state, showTextFormatting = showTextFormatting, voiceMessageState = voiceMessageState, permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented") }, composerMode = composerMode, - enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, currentUserId = currentUserId, onRequestFocus = {}, 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..98842c35de2 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.content.Context +import androidx.appcompat.widget.AppCompatEditText + +internal class MarkdownEditText( + context: Context, +) : AppCompatEditText(context) { + var onSelectionChangeListener: ((Int, Int) -> Unit)? = null + + private var isModifyingText = false + + fun updateEditableText(charSequence: CharSequence) { + isModifyingText = true + editableText.clear() + editableText.append(charSequence) + isModifyingText = false + } + + override fun setText(text: CharSequence?, type: BufferType?) { + isModifyingText = true + super.setText(text, type) + isModifyingText = false + } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + if (!isModifyingText) { + onSelectionChangeListener?.invoke(selStart, selEnd) + } + } +} 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..451eaca53f0 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.graphics.Color +import android.text.Editable +import android.text.Selection +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.getSpans +import androidx.core.view.setPadding +import androidx.core.widget.addTextChangedListener +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.wysiwyg.compose.RichTextEditorStyle +import io.element.android.wysiwyg.compose.internal.applyStyleInCompose + +@Suppress("ModifierMissing") +@Composable +fun MarkdownTextInput( + state: MarkdownTextEditorState, + subcomposing: Boolean, + onTyping: (Boolean) -> Unit, + onSuggestionReceived: (Suggestion?) -> Unit, + richTextEditorStyle: RichTextEditorStyle, +) { + val canUpdateState = !subcomposing + AndroidView( + modifier = Modifier + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + factory = { context -> + MarkdownEditText(context).apply { + tag = TestTags.plainTextEditor.value // Needed for UI tests + setPadding(0) + setBackgroundColor(Color.TRANSPARENT) + setText(state.text.value()) + if (canUpdateState) { + setSelection(state.selection.first, state.selection.last) + setOnFocusChangeListener { _, hasFocus -> + state.hasFocus = hasFocus + } + addTextChangedListener { editable -> + onTyping(!editable.isNullOrEmpty()) + state.text.update(editable, false) + state.lineCount = lineCount + + state.currentMentionSuggestion = editable?.checkSuggestionNeeded() + onSuggestionReceived(state.currentMentionSuggestion) + } + onSelectionChangeListener = { selStart, selEnd -> + state.selection = selStart..selEnd + state.currentMentionSuggestion = editableText.checkSuggestionNeeded() + onSuggestionReceived(state.currentMentionSuggestion) + } + state.requestFocusAction = { this.requestFocus() } + } + } + }, + update = { editText -> + editText.applyStyleInCompose(richTextEditorStyle) + + if (state.text.needsDisplaying()) { + editText.updateEditableText(state.text.value()) + if (canUpdateState) { + state.text.update(editText.editableText, false) + } + } + if (canUpdateState) { + val newSelectionStart = state.selection.first + val newSelectionEnd = state.selection.last + val currentTextRange = 0..editText.editableText.length + val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd } + val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange } + if (didSelectionChange() && isNewSelectionValid()) { + editText.setSelection(state.selection.first, state.selection.last) + } + } + } + ) +} + +private fun Editable.checkSuggestionNeeded(): Suggestion? { + if (this.isEmpty()) return null + val start = Selection.getSelectionStart(this) + val end = Selection.getSelectionEnd(this) + var startOfWord = start + while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) { + startOfWord-- + } + if (startOfWord !in indices) return null + val firstChar = this[startOfWord] + + // If a mention span already exists we don't need suggestions + if (getSpans(startOfWord, startOfWord + 1).isNotEmpty()) return null + + return if (firstChar in listOf('@', '#', '/')) { + var endOfWord = end + while (endOfWord < this.length && !this[endOfWord].isWhitespace()) { + endOfWord++ + } + val text = this.subSequence(startOfWord + 1, endOfWord).toString() + val suggestionType = when (firstChar) { + '@' -> SuggestionType.Mention + '#' -> SuggestionType.Room + '/' -> SuggestionType.Command + else -> error("Unknown suggestion type. This should never happen.") + } + Suggestion(startOfWord, endOfWord, suggestionType, text) + } else { + null + } +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextInputPreview() { + ElementPreview { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = true) + MarkdownTextInput( + state = aMarkdownTextEditorState(), + subcomposing = false, + onTyping = {}, + onSuggestionReceived = {}, + richTextEditorStyle = style, + ) + } +} + +internal fun aMarkdownTextEditorState( + initialText: String = "Hello, World!", + initialFocus: Boolean = true, +) = MarkdownTextEditorState( + initialText = initialText, + initialFocus = initialFocus, +) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt new file mode 100644 index 00000000000..5491f9ccf47 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.text.SpannableString +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.element.android.libraries.core.extensions.orEmpty + +@Stable +class StableCharSequence(initialText: CharSequence = "") { + private var value by mutableStateOf(SpannableString(initialText)) + private var needsDisplaying by mutableStateOf(false) + + fun update(newText: CharSequence?, needsDisplaying: Boolean) { + value = SpannableString(newText.orEmpty()) + this.needsDisplaying = needsDisplaying + } + + fun value(): CharSequence = value + fun needsDisplaying(): Boolean = needsDisplaying + + override fun toString(): String { + return "ImmutableCharSequence(value='$value', needsDisplaying=$needsDisplaying)" + } +} 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..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 @@ -26,6 +26,8 @@ import kotlin.math.min import kotlin.math.roundToInt class MentionSpan( + val text: String, + val rawValue: String, val type: Type, val backgroundColor: Int, val textColor: Int, @@ -39,29 +41,25 @@ class MentionSpan( private var actualText: CharSequence? = null private var textWidth = 0 - private var cachedRect: RectF = RectF() private val backgroundPaint = Paint().apply { isAntiAlias = true color = backgroundColor } override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { - val mentionText = getActualText(text, start, end) + val mentionText = getActualText(this.text) paint.typeface = typeface textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt() return textWidth + startPadding + endPadding } override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { - val mentionText = getActualText(text, start, end) + val mentionText = getActualText(this.text) // Extra vertical space to add below the baseline (y). This helps us center the span vertically val extraVerticalSpace = y + paint.ascent() + paint.descent() - top - if (cachedRect.isEmpty) { - cachedRect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace) - } - val rect = cachedRect + val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace) val radius = rect.height() / 2 canvas.drawRoundRect(rect, radius, radius, backgroundPaint) paint.color = textColor @@ -69,24 +67,24 @@ class MentionSpan( canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint) } - private fun getActualText(text: CharSequence?, start: Int, end: Int): CharSequence { + private fun getActualText(text: String): CharSequence { if (actualText != null) return actualText!! return buildString { val mentionText = text.orEmpty() when (type) { Type.USER -> { - if (start in mentionText.indices && mentionText[start] != '@') { + if (text.firstOrNull() != '@') { append("@") } } Type.ROOM -> { - if (start in mentionText.indices && mentionText[start] != '#') { + if (text.firstOrNull() != '#') { append("#") } } } - append(mentionText.substring(start, min(end, start + MAX_LENGTH))) - if (end - start > MAX_LENGTH) { + append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH))) + if (mentionText.length > MAX_LENGTH) { append("…") } actualText = this 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..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 @@ -84,6 +84,8 @@ class MentionSpanProvider( permalinkData is PermalinkData.UserLink -> { val isCurrentUser = permalinkData.userId == currentSessionId MentionSpan( + text = text, + rawValue = permalinkData.userId.toString(), type = MentionSpan.Type.USER, backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor, textColor = if (isCurrentUser) currentUserTextColor else otherTextColor, @@ -94,6 +96,8 @@ class MentionSpanProvider( } text == "@room" && permalinkData is PermalinkData.FallbackLink -> { MentionSpan( + text = text, + rawValue = "@room", type = MentionSpan.Type.USER, backgroundColor = otherBackgroundColor, textColor = otherTextColor, @@ -102,8 +106,22 @@ class MentionSpanProvider( typeface = typeface.value, ) } + permalinkData is PermalinkData.RoomLink -> { + MentionSpan( + text = text, + rawValue = permalinkData.roomIdOrAlias.toString(), + type = MentionSpan.Type.ROOM, + backgroundColor = otherBackgroundColor, + textColor = otherTextColor, + startPadding = startPaddingPx, + endPadding = endPaddingPx, + typeface = typeface.value, + ) + } else -> { MentionSpan( + text = text, + rawValue = text, type = MentionSpan.Type.ROOM, backgroundColor = otherBackgroundColor, textColor = otherTextColor, @@ -155,8 +173,8 @@ internal fun MentionSpanPreview() { provider.setup() val textColor = ElementTheme.colors.textPrimary.toArgb() - fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org") - fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org") + fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") + fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org") fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org") AndroidView(factory = { context -> TextView(context).apply { 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..03bc48f53df 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 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..7cda8f421cf --- /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.StableCharSequence +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion + +@Stable +class MarkdownTextEditorState( + initialText: String?, + initialFocus: Boolean, +) { + var text by mutableStateOf(StableCharSequence(initialText ?: "")) + var selection by mutableStateOf(0..0) + var hasFocus by mutableStateOf(initialFocus) + var requestFocusAction by mutableStateOf({}) + var lineCount by mutableIntStateOf(1) + var currentMentionSuggestion by mutableStateOf(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 new file mode 100644 index 00000000000..ae7a15fb65d --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.model + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.wysiwyg.compose.RichTextEditorState + +@Immutable +sealed interface TextEditorState { + data class Markdown( + val state: MarkdownTextEditorState, + ) : TextEditorState + + data class Rich( + val richTextEditorState: RichTextEditorState + ) : TextEditorState + + fun messageHtml(): String? = when (this) { + is Markdown -> null + is Rich -> richTextEditorState.messageHtml + } + + fun messageMarkdown(permalinkBuilder: PermalinkBuilder): String = when (this) { + is Markdown -> state.getMessageMarkdown(permalinkBuilder) + is Rich -> richTextEditorState.messageMarkdown + } + + fun hasFocus(): Boolean = when (this) { + is Markdown -> state.hasFocus + is Rich -> richTextEditorState.hasFocus + } + + suspend fun reset() { + when (this) { + is Markdown -> { + state.selection = IntRange.EMPTY + state.text.update("", true) + } + is Rich -> richTextEditorState.setHtml("") + } + } + + suspend fun requestFocus() { + when (this) { + is Markdown -> state.requestFocusAction() + is Rich -> richTextEditorState.requestFocus() + } + } + + val lineCount: Int get() = when (this) { + is Markdown -> state.lineCount + is Rich -> richTextEditorState.lineCount + } +} 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..8967d4cf6a3 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.impl.components.markdown + +import android.widget.EditText +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.core.text.getSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.tests.testutils.EnsureCalledOnceWithParam +import io.element.android.tests.testutils.EventsRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkdownTextInputTest { + @get:Rule val rule = createAndroidComposeRule() + + @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("") + editText.setText(null) + } + rule.awaitIdle() + onTyping.assertList(listOf(true, false, false)) + } + + @Test + fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onSuggestionReceived.assertSingle(null) + } + + @Test + fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("@") + it.findEditor().setText("#") + it.findEditor().setText("/") + } + rule.awaitIdle() + onSuggestionReceived.assertList( + listOf( + // User mention suggestion + Suggestion(0, 1, SuggestionType.Mention, ""), + // Room suggestion + Suggestion(0, 1, SuggestionType.Room, ""), + // Slash command suggestion + Suggestion(0, 1, SuggestionType.Command, ""), + ) + ) + } + + @Test + fun `when the selection changes in the UI the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.setSelection(2) + } + rule.awaitIdle() + // Selection is updated + assertThat(state.selection).isEqualTo(2..2) + } + + @Test + fun `when the selection state changes in the view is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.selection = 2..2 + } + rule.awaitIdle() + // Selection state is updated + assertThat(editor?.selectionStart).isEqualTo(2) + assertThat(editor?.selectionEnd).isEqualTo(2) + } + + @Test + fun `when the view focus changes the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.requestFocus() + } + // Focus state is updated + assertThat(state.hasFocus).isTrue() + } + + @Test + fun `inserting a mention replaces the existing text with a span`() = runTest { + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$A_SESSION_ID") }) + val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true) + state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.insertMention( + ResolvedMentionSuggestion.Member(roomMember = aRoomMember()), + MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser), + permalinkBuilder, + ) + } + rule.awaitIdle() + + // Text is replaced with a placeholder + assertThat(editor?.editableText.toString()).isEqualTo(". ") + // The placeholder contains a MentionSpan + val mentionSpans = editor?.editableText?.getSpans(0, 2).orEmpty() + assertThat(mentionSpans).isNotEmpty() + } + + private fun AndroidComposeTestRule.setMarkdownTextInput( + state: MarkdownTextEditorState = aMarkdownTextEditorState(), + subcomposing: Boolean = false, + onTyping: (Boolean) -> Unit = {}, + onSuggestionReceived: (Suggestion?) -> Unit = {}, + ) { + rule.setContent { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) + MarkdownTextInput( + state = state, + subcomposing = subcomposing, + onTyping = onTyping, + onSuggestionReceived = onSuggestionReceived, + richTextEditorStyle = style, + ) + } + } + + private fun ComponentActivity.findEditor(): EditText { + return window.decorView.findViewWithTag(TestTags.plainTextEditor.value) + } +} 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( 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("@") + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index cd3689abe37..dd48d5d4af6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,6 +27,15 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + // Snapshot versions + maven { + url = URI("https://s01.oss.sonatype.org/content/repositories/snapshots") + content { + includeModule("org.matrix.rustcomponents", "sdk-android") + includeModule("io.element.android", "wysiwyg") + includeModule("io.element.android", "wysiwyg-compose") + } + } google() mavenCentral() maven { url = URI("https://oss.sonatype.org/content/repositories/snapshots/") } 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", 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