From f214493c9da729145a60767124ac3ca7360eadef Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Thu, 7 Sep 2023 16:21:29 +0100 Subject: [PATCH] [Rich text editor] Integrate rich text editor library (#1172) * Integrate rich text editor * Also increase swapfile size in test CI Fixes issue where screenshot tests are terminated due to lack of CI resources. See https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749 --------- Co-authored-by: ElementBot --- .github/workflows/tests.yml | 10 + changelog.d/1172.feature | 2 + features/messages/api/build.gradle.kts | 2 +- features/messages/impl/build.gradle.kts | 3 +- .../messages/impl/MessagesPresenter.kt | 6 +- .../messages/impl/MessagesStateProvider.kt | 5 +- .../impl/actionlist/ActionListPresenter.kt | 2 +- .../messagecomposer/MessageComposerEvents.kt | 6 +- .../MessageComposerPresenter.kt | 43 ++-- .../messagecomposer/MessageComposerState.kt | 9 +- .../MessageComposerStateProvider.kt | 8 +- .../messagecomposer/MessageComposerView.kt | 19 +- .../RichTextEditorStateFactory.kt | 38 ++++ .../impl/timeline/TimelinePresenter.kt | 5 +- .../customreaction/CustomReactionPresenter.kt | 2 +- .../ReactionSummaryPresenter.kt | 2 +- .../retrysendmenu/RetrySendMenuPresenter.kt | 2 +- .../event/TimelineItemTextBasedContent.kt | 2 + .../messages/MessagesPresenterTest.kt | 11 +- .../MessageComposerPresenterTest.kt | 124 ++++++----- .../TestRichTextEditorStateFactory.kt | 29 +++ gradle/libs.versions.toml | 3 + .../libraries/matrix/api/room/MatrixRoom.kt | 6 +- .../matrix/impl/room/RustMatrixRoom.kt | 31 +-- .../matrix/test/room/FakeMatrixRoom.kt | 14 +- .../impl/DefaultPermissionsPresenter.kt | 2 +- .../textcomposer/{ => impl}/build.gradle.kts | 6 +- .../android/libraries/textcomposer/Message.kt | 22 ++ .../textcomposer/MessageComposerMode.kt | 0 .../libraries/textcomposer/TextComposer.kt | 210 ++++++++---------- .../main/res/drawable/ic_add_attachment.xml | 0 .../src/main/res/drawable/ic_send.xml | 0 .../src/main/res/drawable/ic_tick.xml | 0 .../src/main/res/values-cs/translations.xml | 0 .../src/main/res/values-de/translations.xml | 0 .../src/main/res/values-ro/translations.xml | 0 .../src/main/res/values-ru/translations.xml | 0 .../src/main/res/values-sk/translations.xml | 0 .../main/res/values-zh-rTW/translations.xml | 0 .../src/main/res/values/localazy.xml | 0 libraries/textcomposer/test/build.gradle.kts | 28 +++ .../kotlin/extension/DependencyHandleScope.kt | 2 +- .../analytics/test/FakeAnalyticsService.kt | 2 + ...poserViewDark_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...oserViewLight_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...sagesViewDark_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...sagesViewDark_0_null_1,NEXUS_5,1.0,en].png | 4 +- ...sagesViewDark_0_null_2,NEXUS_5,1.0,en].png | 4 +- ...sagesViewDark_0_null_4,NEXUS_5,1.0,en].png | 4 +- ...sagesViewDark_0_null_5,NEXUS_5,1.0,en].png | 4 +- ...agesViewLight_0_null_0,NEXUS_5,1.0,en].png | 4 +- ...agesViewLight_0_null_1,NEXUS_5,1.0,en].png | 4 +- ...agesViewLight_0_null_2,NEXUS_5,1.0,en].png | 4 +- ...agesViewLight_0_null_4,NEXUS_5,1.0,en].png | 4 +- ...agesViewLight_0_null_5,NEXUS_5,1.0,en].png | 4 +- ...omposerEdit-D-1_2_null,NEXUS_5,1.0,en].png | 4 +- ...omposerEdit-N-1_3_null,NEXUS_5,1.0,en].png | 4 +- ...mposerReply-D-2_3_null,NEXUS_5,1.0,en].png | 4 +- ...mposerReply-N-2_4_null,NEXUS_5,1.0,en].png | 4 +- ...poserSimple-D-0_1_null,NEXUS_5,1.0,en].png | 4 +- ...poserSimple-N-0_2_null,NEXUS_5,1.0,en].png | 4 +- tools/localazy/config.json | 2 +- 62 files changed, 441 insertions(+), 289 deletions(-) create mode 100644 changelog.d/1172.feature create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt rename libraries/textcomposer/{ => impl}/build.gradle.kts (89%) create mode 100644 libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/Message.kt rename libraries/textcomposer/{ => impl}/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt (100%) rename libraries/textcomposer/{ => impl}/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt (74%) rename libraries/textcomposer/{ => impl}/src/main/res/drawable/ic_add_attachment.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/drawable/ic_send.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/drawable/ic_tick.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/values-cs/translations.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/values-de/translations.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/values-ro/translations.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/values-ru/translations.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/values-sk/translations.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/values-zh-rTW/translations.xml (100%) rename libraries/textcomposer/{ => impl}/src/main/res/values/localazy.xml (100%) create mode 100644 libraries/textcomposer/test/build.gradle.kts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 460e57b4e3..f662e5352d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,6 +22,16 @@ jobs: group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }} cancel-in-progress: true steps: + # Increase swapfile size to prevent screenshot tests getting terminated + # https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749 + - name: 💽 Increase swapfile size + run: | + sudo swapoff -a + sudo fallocate -l 8G /mnt/swapfile + sudo chmod 600 /mnt/swapfile + sudo mkswap /mnt/swapfile + sudo swapon /mnt/swapfile + sudo swapon --show - name: ⏬ Checkout with LFS uses: nschloe/action-cached-lfs-checkout@v1.2.2 with: diff --git a/changelog.d/1172.feature b/changelog.d/1172.feature new file mode 100644 index 0000000000..ea03101f0c --- /dev/null +++ b/changelog.d/1172.feature @@ -0,0 +1,2 @@ +[Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor. + diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index 756014e97d..9e890265ec 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -25,5 +25,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - api(projects.libraries.textcomposer) + api(projects.libraries.textcomposer.impl) } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 1a61a2d8b6..00d65eba6a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.textcomposer) + implementation(projects.libraries.textcomposer.impl) implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.eventformatter.api) @@ -76,6 +76,7 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.textcomposer.test) testImplementation(libs.test.mockk) ksp(libs.showkase.processor) 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 dc6f3304b1..b0ff403984 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 @@ -175,7 +175,7 @@ class MessagesPresenter @AssistedInject constructor( snackbarMessage = snackbarMessage, showReinvitePrompt = showReinvitePrompt, inviteProgress = inviteProgress.value, - eventSink = ::handleEvents + eventSink = { handleEvents(it) } ) } @@ -250,7 +250,9 @@ class MessagesPresenter @AssistedInject constructor( private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { val composerMode = MessageComposerMode.Edit( targetEvent.eventId, - (targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(), + (targetEvent.content as? TimelineItemTextBasedContent)?.let { + it.htmlBody ?: it.body + }.orEmpty(), targetEvent.transactionId, ) composerState.eventSink( 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 7eb1a0984e..6ca799dc84 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 @@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.persistentSetOf open class MessagesStateProvider : PreviewParameterProvider { @@ -54,7 +55,9 @@ fun aMessagesState() = MessagesState( userHasPermissionToSendMessage = true, userHasPermissionToRedact = false, composerState = aMessageComposerState().copy( - text = "Hello", + richTextEditorState = RichTextEditorState("Hello", fake = true).apply { + requestFocus() + }, isFullScreen = false, mode = MessageComposerMode.Normal("Hello"), ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index f5e28818c9..d0fa46175a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -70,7 +70,7 @@ class ActionListPresenter @Inject constructor( return ActionListState( target = target.value, displayEmojiReactions = displayEmojiReactions, - eventSink = ::handleEvents + eventSink = { handleEvents(it) } ) } 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 d99eb3c158..4bfd290a77 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 @@ -17,16 +17,15 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable +import io.element.android.libraries.textcomposer.Message import io.element.android.libraries.textcomposer.MessageComposerMode @Immutable sealed interface MessageComposerEvents { data object ToggleFullScreenState : MessageComposerEvents - data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents - data class SendMessage(val message: String) : MessageComposerEvents + data class SendMessage(val message: Message) : MessageComposerEvents data object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents - data class UpdateText(val text: String) : MessageComposerEvents data object AddAttachment : MessageComposerEvents data object DismissAttachmentMenu : MessageComposerEvents sealed interface PickAttachmentSource : MessageComposerEvents { @@ -38,4 +37,5 @@ sealed interface MessageComposerEvents { data object Poll : PickAttachmentSource } data object CancelSendAttachment : MessageComposerEvents + data class Error(val error: Throwable) : 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 cc735dc008..687e951933 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 @@ -44,8 +44,10 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.textcomposer.Message import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -67,6 +69,7 @@ class MessageComposerPresenter @Inject constructor( private val snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, private val messageComposerContext: MessageComposerContextImpl, + private val richTextEditorStateFactory: RichTextEditorStateFactory, ) : Presenter { @SuppressLint("UnsafeOptInUsageError") @@ -103,19 +106,15 @@ class MessageComposerPresenter @Inject constructor( val isFullScreen = rememberSaveable { mutableStateOf(false) } - val hasFocus = remember { - mutableStateOf(false) - } - val text: MutableState = rememberSaveable { - mutableStateOf("") - } + val richTextEditorState = richTextEditorStateFactory.create() val ongoingSendAttachmentJob = remember { mutableStateOf(null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } LaunchedEffect(messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) { - is MessageComposerMode.Edit -> text.value = modeValue.defaultContent + is MessageComposerMode.Edit -> + richTextEditorState.setHtml(modeValue.defaultContent) else -> Unit } } @@ -136,18 +135,15 @@ class MessageComposerPresenter @Inject constructor( when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value - is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus - - is MessageComposerEvents.UpdateText -> text.value = event.text MessageComposerEvents.CloseSpecialMode -> { - text.value = "" + richTextEditorState.setHtml("") messageComposerContext.composerMode = MessageComposerMode.Normal("") } is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( - text = event.message, + message = event.message, updateComposerMode = { messageComposerContext.composerMode = it }, - textState = text + richTextEditorState = richTextEditorState, ) is MessageComposerEvents.SetMode -> { messageComposerContext.composerMode = event.composerMode @@ -194,43 +190,46 @@ class MessageComposerPresenter @Inject constructor( ongoingSendAttachmentJob.value == null } } + is MessageComposerEvents.Error -> { + analyticsService.trackError(event.error) + } } } return MessageComposerState( - text = text.value, + richTextEditorState = richTextEditorState, isFullScreen = isFullScreen.value, - hasFocus = hasFocus.value, mode = messageComposerContext.composerMode, showAttachmentSourcePicker = showAttachmentSourcePicker, canShareLocation = canShareLocation.value, canCreatePoll = canCreatePoll.value, attachmentsState = attachmentsState.value, - eventSink = ::handleEvents + eventSink = { handleEvents(it) } ) } private fun CoroutineScope.sendMessage( - text: String, + message: Message, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, - textState: MutableState + richTextEditorState: RichTextEditorState, ) = launch { val capturedMode = messageComposerContext.composerMode // Reset composer right away - textState.value = "" + richTextEditorState.setHtml("") updateComposerMode(MessageComposerMode.Normal("")) when (capturedMode) { - is MessageComposerMode.Normal -> room.sendMessage(text) + is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html) is MessageComposerMode.Edit -> { val eventId = capturedMode.eventId val transactionId = capturedMode.transactionId - room.editMessage(eventId, transactionId, text) + room.editMessage(eventId, transactionId, message.markdown, message.html) } is MessageComposerMode.Quote -> TODO() is MessageComposerMode.Reply -> room.replyMessage( capturedMode.eventId, - text + message.markdown, + message.html, ) } } 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 dbbc62ca47..bdff621521 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,21 +19,22 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.ImmutableList @Immutable data class MessageComposerState( - val text: String?, + val richTextEditorState: RichTextEditorState, val isFullScreen: Boolean, - val hasFocus: Boolean, val mode: MessageComposerMode, val showAttachmentSourcePicker: Boolean, val canShareLocation: Boolean, val canCreatePoll: Boolean, val attachmentsState: AttachmentsState, - val eventSink: (MessageComposerEvents) -> Unit + val eventSink: (MessageComposerEvents) -> Unit, ) { - val isSendButtonVisible: Boolean = text.isNullOrEmpty().not() + val canSendMessage: Boolean = richTextEditorState.messageHtml.isNotEmpty() + val hasFocus: Boolean = richTextEditorState.hasFocus } @Immutable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 2217b574b4..ab15d8ccd8 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 @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.wysiwyg.compose.RichTextEditorState open class MessageComposerStateProvider : PreviewParameterProvider { override val values: Sequence @@ -27,18 +28,17 @@ open class MessageComposerStateProvider : PreviewParameterProvider(null) } val timelineItems by timelineItemsFactory.collectItemsAsState() @@ -119,7 +120,7 @@ class TimelinePresenter @Inject constructor( paginationState = paginationState, timelineItems = timelineItems, hasNewItems = hasNewItems.value, - eventSink = ::handleEvents + eventSink = { handleEvents(it) } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt index b048383b1f..8bbd6cbff7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -63,7 +63,7 @@ class CustomReactionPresenter @Inject constructor( return CustomReactionState( target = target.value, selectedEmoji = selectedEmoji, - eventSink = ::handleEvents + eventSink = { handleEvents(it) } ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt index 456ac5f548..e75e49c1e3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt @@ -61,7 +61,7 @@ class ReactionSummaryPresenter @Inject constructor( } return ReactionSummaryState( target = targetWithAvatars.value, - eventSink = ::handleEvents + eventSink = { handleEvents(it) } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt index 237dc5683d..c9ebd9be8c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt @@ -66,7 +66,7 @@ class RetrySendMenuPresenter @Inject constructor( return RetrySendMenuState( selectedEvent = selectedEvent, - eventSink = ::handleEvent, + eventSink = { handleEvent(it) }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt index ec6ee16675..10fca53261 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt @@ -22,4 +22,6 @@ sealed interface TimelineItemTextBasedContent : TimelineItemEventContent { val body: String val htmlDocument: Document? val isEdited: Boolean + val htmlBody: String? + get() = htmlDocument?.body()?.html() } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 48a8480915..ed7c4a8064 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -30,7 +30,6 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl -import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter @@ -41,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.media.FakeLocalMediaFactory +import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.networkmonitor.test.FakeNetworkMonitor @@ -325,6 +325,7 @@ class MessagesPresenterTest { initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + skipItems(1) // back paginating } } @@ -381,7 +382,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.eventSink(MessageComposerEvents.FocusChanged(true)) + initialState.composerState.richTextEditorState.requestFocus() val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state -> state.showReinvitePrompt }.last() @@ -405,7 +406,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) + initialState.composerState.richTextEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -421,7 +422,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) + initialState.composerState.richTextEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -605,6 +606,8 @@ class MessagesPresenterTest { snackbarDispatcher = SnackbarDispatcher(), analyticsService = FakeAnalyticsService(), messageComposerContext = MessageComposerContextImpl(), + richTextEditorStateFactory = TestRichTextEditorStateFactory(), + ) val timelinePresenter = TimelinePresenter( timelineItemsFactory = aTimelineItemsFactory(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index d89a0392ad..7284fb3b7e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -53,6 +53,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.textcomposer.Message import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule @@ -80,6 +81,7 @@ class MessageComposerPresenterTest { private val snackbarDispatcher = SnackbarDispatcher() private val mockMediaUrl: Uri = mockk("localMediaUri") private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) + private val analyticsService = FakeAnalyticsService() @Test fun `present - initial state`() = runTest { @@ -90,12 +92,12 @@ class MessageComposerPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.isFullScreen).isFalse() - assertThat(initialState.text).isEqualTo("") + assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal("")) assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None) - assertThat(initialState.isSendButtonVisible).isFalse() + assertThat(initialState.canSendMessage).isFalse() } } @@ -124,14 +126,14 @@ class MessageComposerPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) + initialState.richTextEditorState.setHtml(A_MESSAGE) val withMessageState = awaitItem() - assertThat(withMessageState.text).isEqualTo(A_MESSAGE) - assertThat(withMessageState.isSendButtonVisible).isTrue() - withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText("")) + assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(withMessageState.canSendMessage).isTrue() + withMessageState.richTextEditorState.setHtml("") val withEmptyMessageState = awaitItem() - assertThat(withEmptyMessageState.text).isEqualTo("") - assertThat(withEmptyMessageState.isSendButtonVisible).isFalse() + assertThat(withEmptyMessageState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(withEmptyMessageState.canSendMessage).isFalse() } } @@ -148,8 +150,8 @@ class MessageComposerPresenterTest { state = awaitItem() assertThat(state.mode).isEqualTo(mode) state = awaitItem() - assertThat(state.text).isEqualTo(A_MESSAGE) - assertThat(state.isSendButtonVisible).isTrue() + assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(state.canSendMessage).isTrue() backToNormalMode(state, skipCount = 1) } } @@ -166,8 +168,8 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.text).isEqualTo("") - assertThat(state.isSendButtonVisible).isFalse() + assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.canSendMessage).isFalse() backToNormalMode(state) } } @@ -184,8 +186,8 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.text).isEqualTo("") - assertThat(state.isSendButtonVisible).isFalse() + assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.canSendMessage).isFalse() backToNormalMode(state) } } @@ -198,14 +200,14 @@ class MessageComposerPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) + initialState.richTextEditorState.setHtml(A_MESSAGE) val withMessageState = awaitItem() - assertThat(withMessageState.text).isEqualTo(A_MESSAGE) - assertThat(withMessageState.isSendButtonVisible).isTrue() - withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE)) + assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(withMessageState.canSendMessage).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) val messageSentState = awaitItem() - assertThat(messageSentState.text).isEqualTo("") - assertThat(messageSentState.isSendButtonVisible).isFalse() + assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.canSendMessage).isFalse() } } @@ -221,23 +223,23 @@ class MessageComposerPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.text).isEqualTo("") + assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") val mode = anEditMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) skipItems(1) val withMessageState = awaitItem() assertThat(withMessageState.mode).isEqualTo(mode) - assertThat(withMessageState.text).isEqualTo(A_MESSAGE) - assertThat(withMessageState.isSendButtonVisible).isTrue() - withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE)) + assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(withMessageState.canSendMessage).isTrue() + withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE)) + assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.text).isEqualTo("") - assertThat(messageSentState.isSendButtonVisible).isFalse() - assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE) + assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.canSendMessage).isFalse() + assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE) } } @@ -253,23 +255,23 @@ class MessageComposerPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.text).isEqualTo("") + assertThat(initialState.richTextEditorState.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.text).isEqualTo(A_MESSAGE) - assertThat(withMessageState.isSendButtonVisible).isTrue() - withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE)) + assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(withMessageState.canSendMessage).isTrue() + withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE)) + assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.text).isEqualTo("") - assertThat(messageSentState.isSendButtonVisible).isFalse() - assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE) + assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.canSendMessage).isFalse() + assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE to ANOTHER_MESSAGE) } } @@ -285,23 +287,23 @@ class MessageComposerPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.text).isEqualTo("") + assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") val mode = aReplyMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) val state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.text).isEqualTo("") - assertThat(state.isSendButtonVisible).isFalse() - initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY)) + assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.canSendMessage).isFalse() + state.richTextEditorState.setHtml(A_REPLY) val withMessageState = awaitItem() - assertThat(withMessageState.text).isEqualTo(A_REPLY) - assertThat(withMessageState.isSendButtonVisible).isTrue() - withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY)) + assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_REPLY) + assertThat(withMessageState.canSendMessage).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage())) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.text).isEqualTo("") - assertThat(messageSentState.isSendButtonVisible).isFalse() - assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY) + assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.canSendMessage).isFalse() + assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY to A_REPLY) } } @@ -523,13 +525,27 @@ class MessageComposerPresenterTest { } } + @Test + fun `present - errors are tracked`() = runTest { + val testException = Exception("Test error") + val presenter = createPresenter(this) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.Error(testException)) + assertThat(analyticsService.trackedErrors).containsExactly(testException) + } + } + private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) { state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) skipItems(skipCount) val normalState = awaitItem() assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal("")) - assertThat(normalState.text).isEqualTo("") - assertThat(normalState.isSendButtonVisible).isFalse() + assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(normalState.canSendMessage).isFalse() } private fun createPresenter( @@ -547,8 +563,9 @@ class MessageComposerPresenterTest { localMediaFactory, MediaSender(mediaPreProcessor, room), snackbarDispatcher, - FakeAnalyticsService(), + analyticsService, MessageComposerContextImpl(), + TestRichTextEditorStateFactory(), ) } @@ -560,3 +577,8 @@ fun anEditMode( fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) + +private fun String.toMessage() = Message( + html = this, + markdown = this, +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt new file mode 100644 index 0000000000..762d144cd6 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/TestRichTextEditorStateFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 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.features.messages.textcomposer + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.messagecomposer.RichTextEditorStateFactory +import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.wysiwyg.compose.rememberRichTextEditorState + +class TestRichTextEditorStateFactory : RichTextEditorStateFactory { + @Composable + override fun create(): RichTextEditorState { + return rememberRichTextEditorState("", fake = true) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 877dd7955f..ebbc937bfa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ dependencyanalysis = "1.21.0" stem = "2.3.0" sqldelight = "1.5.5" telephoto = "0.6.0" +wysiwyg = "2.9.0" # DI dagger = "2.48" @@ -147,6 +148,8 @@ appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.49" +matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } +matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 60c8b1ddd5..169381f25e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -79,11 +79,11 @@ interface MatrixRoom : Closeable { suspend fun userAvatarUrl(userId: UserId): Result - suspend fun sendMessage(message: String): Result + suspend fun sendMessage(body: String, htmlBody: String): Result - suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result + suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result - suspend fun replyMessage(eventId: EventId, message: String): Result + suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result suspend fun redactEvent(eventId: EventId, reason: String? = null): Result diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 461e43931e..9e75d10aad 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -66,7 +66,7 @@ import org.matrix.rustcomponents.sdk.RoomMember import org.matrix.rustcomponents.sdk.RoomSubscription import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle import org.matrix.rustcomponents.sdk.genTransactionId -import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown +import org.matrix.rustcomponents.sdk.messageEventContentFromHtml import timber.log.Timber import java.io.File @@ -227,31 +227,32 @@ class RustMatrixRoom( } } - override suspend fun sendMessage(message: String): Result = withContext(roomDispatcher) { + override suspend fun sendMessage(body: String, htmlBody: String): Result = withContext(roomDispatcher) { val transactionId = genTransactionId() - messageEventContentFromMarkdown(message).use { content -> + messageEventContentFromHtml(body, htmlBody).use { content -> runCatching { innerRoom.send(content, transactionId) } } } - override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result = withContext(roomDispatcher) { - if (originalEventId != null) { - runCatching { - innerRoom.edit(messageEventContentFromMarkdown(message), originalEventId.value, transactionId?.value) - } - } else { - runCatching { - transactionId?.let { cancelSend(it) } - innerRoom.send(messageEventContentFromMarkdown(message), genTransactionId()) + override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result = + withContext(roomDispatcher) { + if (originalEventId != null) { + runCatching { + innerRoom.edit(messageEventContentFromHtml(body, htmlBody), originalEventId.value, transactionId?.value) + } + } else { + runCatching { + transactionId?.let { cancelSend(it) } + innerRoom.send(messageEventContentFromHtml(body, htmlBody), genTransactionId()) + } } } - } - override suspend fun replyMessage(eventId: EventId, message: String): Result = withContext(roomDispatcher) { + override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result = withContext(roomDispatcher) { runCatching { - innerRoom.sendReply(messageEventContentFromMarkdown(message), eventId.value, genTransactionId()) + innerRoom.sendReply(messageEventContentFromHtml(body, htmlBody), eventId.value, genTransactionId()) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 761e479816..24f0e68d10 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -92,7 +92,7 @@ class FakeMatrixRoom( private var sendPollResponseResult = Result.success(Unit) private var endPollResult = Result.success(Unit) private var progressCallbackValues = emptyList>() - val editMessageCalls = mutableListOf() + val editMessageCalls = mutableListOf>() var sendMediaCount = 0 private set @@ -171,7 +171,7 @@ class FakeMatrixRoom( userAvatarUrlResult } - override suspend fun sendMessage(message: String): Result = simulateLongTask { + override suspend fun sendMessage(body: String, htmlBody: String) = simulateLongTask { Result.success(Unit) } @@ -200,16 +200,16 @@ class FakeMatrixRoom( return cancelSendResult } - override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result { - editMessageCalls += message + override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result { + editMessageCalls += body to htmlBody return Result.success(Unit) } - var replyMessageParameter: String? = null + var replyMessageParameter: Pair? = null private set - override suspend fun replyMessage(eventId: EventId, message: String): Result { - replyMessageParameter = message + override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result { + replyMessageParameter = body to htmlBody return Result.success(Unit) } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index ddd45f865f..1684833d70 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -124,7 +124,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( showDialog = showDialog.value, permissionAlreadyAsked = isAlreadyAsked, permissionAlreadyDenied = isAlreadyDenied, - eventSink = ::handleEvents + eventSink = { handleEvents(it) } ) } diff --git a/libraries/textcomposer/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts similarity index 89% rename from libraries/textcomposer/build.gradle.kts rename to libraries/textcomposer/impl/build.gradle.kts index 76d7d00a03..3aaae7ca6e 100644 --- a/libraries/textcomposer/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 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. @@ -31,5 +31,9 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) + + implementation(libs.matrix.richtexteditor) + api(libs.matrix.richtexteditor.compose) + ksp(libs.showkase.processor) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/Message.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/Message.kt new file mode 100644 index 0000000000..0f3a213427 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/Message.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 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 + +data class Message( + val html: String, + val markdown: String, +) diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt similarity index 100% rename from libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt similarity index 74% rename from libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 3c0e0fa723..04fa08c9ce 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -37,38 +37,25 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -88,23 +75,23 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.compose.RichTextEditor +import io.element.android.wysiwyg.compose.RichTextEditorDefaults +import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.coroutines.android.awaitFrame -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun TextComposer( - composerText: String?, + state: RichTextEditorState, composerMode: MessageComposerMode, - composerCanSendMessage: Boolean, + canSendMessage: Boolean, modifier: Modifier = Modifier, - focusRequester: FocusRequester = FocusRequester(), - onSendMessage: (String) -> Unit = {}, + onRequestFocus: () -> Unit = {}, + onSendMessage: (Message) -> Unit = {}, onResetComposerMode: () -> Unit = {}, - onComposerTextChange: (String) -> Unit = {}, onAddAttachment: () -> Unit = {}, - onFocusChanged: (Boolean) -> Unit = {}, + onError: (Throwable) -> Unit = {}, ) { - val text = composerText.orEmpty() Row( modifier.padding( horizontal = 12.dp, @@ -115,10 +102,9 @@ fun TextComposer( Spacer(modifier = Modifier.width(12.dp)) val roundCornerSmall = 20.dp.applyScaleUp() val roundCornerLarge = 28.dp.applyScaleUp() - var lineCount by remember { mutableIntStateOf(0) } - val roundedCornerSize = remember(lineCount, composerMode) { - if (lineCount > 1 || composerMode is MessageComposerMode.Special) { + val roundedCornerSize = remember(state.lineCount, composerMode) { + if (state.lineCount > 1 || composerMode is MessageComposerMode.Special) { roundCornerSmall } else { roundCornerLarge @@ -132,10 +118,15 @@ fun TextComposer( ) val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value) val minHeight = 42.dp.applyScaleUp() - val bgColor = ElementTheme.colors.bgSubtleSecondary - // Change border color depending on focus - var hasFocus by remember { mutableStateOf(false) } - val borderColor = if (hasFocus) ElementTheme.colors.borderDisabled else bgColor + val colors = ElementTheme.colors + val bgColor = colors.bgSubtleSecondary + + val borderColor by remember(state.hasFocus, colors) { + derivedStateOf { + if (state.hasFocus) colors.borderDisabled else bgColor + } + } + Column( modifier = Modifier .fillMaxWidth() @@ -147,66 +138,56 @@ fun TextComposer( ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode) } val defaultTypography = ElementTheme.typography.fontBodyLgRegular + Box { - BasicTextField( + Box( modifier = Modifier - .fillMaxWidth() .heightIn(min = minHeight) - .focusRequester(focusRequester) - .onFocusEvent { - hasFocus = it.hasFocus - onFocusChanged(it.hasFocus) - }, - value = text, - onValueChange = { onComposerTextChange(it) }, - onTextLayout = { - lineCount = it.lineCount - }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary), - cursorBrush = SolidColor(ElementTheme.colors.iconAccentTertiary), - decorationBox = { innerTextField -> - TextFieldDefaults.DecorationBox( - value = text, - innerTextField = innerTextField, - enabled = true, - singleLine = false, - visualTransformation = VisualTransformation.None, - shape = roundedCorners, - contentPadding = PaddingValues( - top = 10.dp.applyScaleUp(), - bottom = 10.dp.applyScaleUp(), + .background(color = bgColor, shape = roundedCorners) + .padding( + PaddingValues( + top = 4.dp.applyScaleUp(), + bottom = 4.dp.applyScaleUp(), start = 12.dp.applyScaleUp(), - end = 42.dp.applyScaleUp(), - ), - interactionSource = remember { MutableInteractionSource() }, - placeholder = { - Text(stringResource(CommonStrings.common_message), style = defaultTypography) - }, - colors = TextFieldDefaults.colors( - unfocusedTextColor = MaterialTheme.colorScheme.secondary, - focusedTextColor = MaterialTheme.colorScheme.primary, - unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, - focusedPlaceholderColor = ElementTheme.colors.textDisabled, - unfocusedIndicatorColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - errorIndicatorColor = Color.Transparent, - unfocusedContainerColor = bgColor, - focusedContainerColor = bgColor, - errorContainerColor = bgColor, - disabledContainerColor = bgColor, + end = 42.dp.applyScaleUp() ) + ), + contentAlignment = Alignment.CenterStart, + ) { + + // Placeholder + if (state.messageHtml.isEmpty()) { + Text( + stringResource(CommonStrings.common_message), + style = defaultTypography.copy( + color = ElementTheme.colors.textDisabled, + ), ) } - ) + + RichTextEditor( + state = state, + modifier = Modifier + .fillMaxWidth(), + style = RichTextEditorDefaults.style( + text = RichTextEditorDefaults.textStyle( + color = if (state.hasFocus) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondary + } + ), + cursor = RichTextEditorDefaults.cursorStyle( + color = ElementTheme.colors.iconAccentTertiary, + ) + ), + onError = onError + ) + } SendButton( - text = text, - canSendMessage = composerCanSendMessage, - onSendMessage = onSendMessage, + canSendMessage = canSendMessage, + onClick = { onSendMessage(Message(html = state.messageHtml, markdown = state.messageMarkdown)) }, composerMode = composerMode, modifier = Modifier.padding(end = 6.dp.applyScaleUp(), bottom = 6.dp.applyScaleUp()) ) @@ -218,7 +199,7 @@ fun TextComposer( val keyboard = LocalSoftwareKeyboardController.current LaunchedEffect(composerMode) { if (composerMode is MessageComposerMode.Special) { - focusRequester.requestFocus() + onRequestFocus() keyboard?.let { awaitFrame() it.show() @@ -241,7 +222,7 @@ private fun ComposerModeView( ReplyToModeView( modifier = modifier.padding(8.dp), senderName = composerMode.senderName, - text = composerMode.defaultContent.toString(), + text = composerMode.defaultContent, attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, onResetComposerMode = onResetComposerMode, ) @@ -385,9 +366,8 @@ private fun AttachmentButton( @Composable private fun BoxScope.SendButton( - text: String, canSendMessage: Boolean, - onSendMessage: (String) -> Unit, + onClick: () -> Unit, composerMode: MessageComposerMode, modifier: Modifier = Modifier, ) { @@ -405,9 +385,8 @@ private fun BoxScope.SendButton( enabled = canSendMessage, interactionSource = interactionSource, indication = rememberRipple(bounded = false), - onClick = { - onSendMessage(text) - }), + onClick = onClick, + ), contentAlignment = Alignment.Center, ) { val iconId = when (composerMode) { @@ -433,28 +412,37 @@ private fun BoxScope.SendButton( internal fun TextComposerSimplePreview() = ElementPreview { Column { TextComposer( + RichTextEditorState("", fake = true).apply { requestFocus() }, + canSendMessage = false, + onSendMessage = {}, + composerMode = MessageComposerMode.Normal(""), + onResetComposerMode = {}, + ) + TextComposer( + RichTextEditorState("A message", fake = true).apply { requestFocus() }, + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Normal(""), onResetComposerMode = {}, - composerCanSendMessage = false, - composerText = "", ) TextComposer( + RichTextEditorState( + "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + fake = true + ).apply { + requestFocus() + }, + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Normal(""), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message", ) TextComposer( + RichTextEditorState("A message without focus", fake = true), + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Normal(""), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", ) } } @@ -463,12 +451,11 @@ internal fun TextComposerSimplePreview() = ElementPreview { @Composable internal fun TextComposerEditPreview() = ElementPreview { TextComposer( + RichTextEditorState("A message", fake = true).apply { requestFocus() }, + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message", ) } @@ -477,8 +464,9 @@ internal fun TextComposerEditPreview() = ElementPreview { internal fun TextComposerReplyPreview() = ElementPreview { Column { TextComposer( + RichTextEditorState("", fake = true), + canSendMessage = false, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Reply( senderName = "Alice", eventId = EventId("$1234"), @@ -488,12 +476,11 @@ internal fun TextComposerReplyPreview() = ElementPreview { "To preview larger textfields and long lines with overflow" ), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message", ) TextComposer( + RichTextEditorState("A message", fake = true), + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Reply( senderName = "Alice", eventId = EventId("$1234"), @@ -506,12 +493,11 @@ internal fun TextComposerReplyPreview() = ElementPreview { defaultContent = "image.jpg" ), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message", ) TextComposer( + RichTextEditorState("A message", fake = true), + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Reply( senderName = "Alice", eventId = EventId("$1234"), @@ -524,12 +510,11 @@ internal fun TextComposerReplyPreview() = ElementPreview { defaultContent = "video.mp4" ), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message", ) TextComposer( + RichTextEditorState("A message", fake = true), + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Reply( senderName = "Alice", eventId = EventId("$1234"), @@ -542,12 +527,11 @@ internal fun TextComposerReplyPreview() = ElementPreview { defaultContent = "logs.txt" ), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message", ) TextComposer( + RichTextEditorState("A message", fake = true).apply { requestFocus() }, + canSendMessage = true, onSendMessage = {}, - onComposerTextChange = {}, composerMode = MessageComposerMode.Reply( senderName = "Alice", eventId = EventId("$1234"), @@ -560,8 +544,6 @@ internal fun TextComposerReplyPreview() = ElementPreview { defaultContent = "Shared location" ), onResetComposerMode = {}, - composerCanSendMessage = true, - composerText = "A message", ) } } diff --git a/libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml b/libraries/textcomposer/impl/src/main/res/drawable/ic_add_attachment.xml similarity index 100% rename from libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml rename to libraries/textcomposer/impl/src/main/res/drawable/ic_add_attachment.xml diff --git a/libraries/textcomposer/src/main/res/drawable/ic_send.xml b/libraries/textcomposer/impl/src/main/res/drawable/ic_send.xml similarity index 100% rename from libraries/textcomposer/src/main/res/drawable/ic_send.xml rename to libraries/textcomposer/impl/src/main/res/drawable/ic_send.xml diff --git a/libraries/textcomposer/src/main/res/drawable/ic_tick.xml b/libraries/textcomposer/impl/src/main/res/drawable/ic_tick.xml similarity index 100% rename from libraries/textcomposer/src/main/res/drawable/ic_tick.xml rename to libraries/textcomposer/impl/src/main/res/drawable/ic_tick.xml diff --git a/libraries/textcomposer/src/main/res/values-cs/translations.xml b/libraries/textcomposer/impl/src/main/res/values-cs/translations.xml similarity index 100% rename from libraries/textcomposer/src/main/res/values-cs/translations.xml rename to libraries/textcomposer/impl/src/main/res/values-cs/translations.xml diff --git a/libraries/textcomposer/src/main/res/values-de/translations.xml b/libraries/textcomposer/impl/src/main/res/values-de/translations.xml similarity index 100% rename from libraries/textcomposer/src/main/res/values-de/translations.xml rename to libraries/textcomposer/impl/src/main/res/values-de/translations.xml diff --git a/libraries/textcomposer/src/main/res/values-ro/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml similarity index 100% rename from libraries/textcomposer/src/main/res/values-ro/translations.xml rename to libraries/textcomposer/impl/src/main/res/values-ro/translations.xml diff --git a/libraries/textcomposer/src/main/res/values-ru/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml similarity index 100% rename from libraries/textcomposer/src/main/res/values-ru/translations.xml rename to libraries/textcomposer/impl/src/main/res/values-ru/translations.xml diff --git a/libraries/textcomposer/src/main/res/values-sk/translations.xml b/libraries/textcomposer/impl/src/main/res/values-sk/translations.xml similarity index 100% rename from libraries/textcomposer/src/main/res/values-sk/translations.xml rename to libraries/textcomposer/impl/src/main/res/values-sk/translations.xml diff --git a/libraries/textcomposer/src/main/res/values-zh-rTW/translations.xml b/libraries/textcomposer/impl/src/main/res/values-zh-rTW/translations.xml similarity index 100% rename from libraries/textcomposer/src/main/res/values-zh-rTW/translations.xml rename to libraries/textcomposer/impl/src/main/res/values-zh-rTW/translations.xml diff --git a/libraries/textcomposer/src/main/res/values/localazy.xml b/libraries/textcomposer/impl/src/main/res/values/localazy.xml similarity index 100% rename from libraries/textcomposer/src/main/res/values/localazy.xml rename to libraries/textcomposer/impl/src/main/res/values/localazy.xml diff --git a/libraries/textcomposer/test/build.gradle.kts b/libraries/textcomposer/test/build.gradle.kts new file mode 100644 index 0000000000..04e36e337d --- /dev/null +++ b/libraries/textcomposer/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 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. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.textcomposer.test" +} + +dependencies { + api(projects.libraries.textcomposer.impl) + implementation(projects.tests.testutils) +} diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index fb082e27a7..592cf3c52a 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -99,7 +99,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:usersearch:impl")) - implementation(project(":libraries:textcomposer")) + implementation(project(":libraries:textcomposer:impl")) } fun DependencyHandlerScope.allServicesImpl() { diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt index f3b3a7fab2..c52f66ef2d 100644 --- a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt @@ -32,6 +32,7 @@ class FakeAnalyticsService( private val isEnabledFlow = MutableStateFlow(isEnabled) private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) val capturedEvents = mutableListOf() + val trackedErrors = mutableListOf() override fun getAvailableAnalyticsProviders(): Set = emptySet() @@ -66,6 +67,7 @@ class FakeAnalyticsService( } override fun trackError(throwable: Throwable) { + trackedErrors += throwable } override suspend fun reset() { diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewDark_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewDark_0_null_0,NEXUS_5,1.0,en].png index f11cec4281..fa68f3bd24 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewDark_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewDark_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:906c643393af8d290f0635d7560eaa54339fc0498744f9ab8139932986d73a8c -size 9740 +oid sha256:4013454094701004f3c132df1ea1c3db2aa56a025b9edea80417fdd16fe55b19 +size 10422 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewLight_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewLight_0_null_0,NEXUS_5,1.0,en].png index eb5a63601c..f6040d0e1a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewLight_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.messagecomposer_null_MessageComposerViewLight_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bd57f885f161316550712dcb471ed213537a395539ed5b9975e17465310803b7 -size 9823 +oid sha256:db74b1655e377e4fd8c35aa18c22ef5f5641586b64d5e582ab2079afeac4b8c3 +size 10775 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_0,NEXUS_5,1.0,en].png index 08734109fb..2a10272dac 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:39b527075df2b3f7afa4f3ecfca1ac93a180855b20d2bd64cb763fb98b0a7b69 -size 51340 +oid sha256:cfc6f54988e4fab08ff443f0c2ea285d47e3d030a8e6c92a1294ee0f85dc1269 +size 51967 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_1,NEXUS_5,1.0,en].png index 60f606fe58..5cf07497cd 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81f196dc08f4de31f4257f6ba89c52ea41a97b107e7c5c04261188e6c572d4d0 -size 52594 +oid sha256:d2ffd4fc93e7da38836fd860ce5e505c5f0c25fdd6558efef480aaeb05d00dd5 +size 53327 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_2,NEXUS_5,1.0,en].png index 6b3be5e81e..41f0170682 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a648d286a3b0536fc97801c3281bb8df432c7ffa28efff66f67cd6cf4c546c10 -size 51579 +oid sha256:96864552336c65b464251c262f330856fc82c69a5f92f89b1357a185d654fccd +size 52252 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_4,NEXUS_5,1.0,en].png index e2266026d1..a022fb7675 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34f9eacfd61965cfde4f420fe6a0fc0d637a21aca0d4b12c6a6f1642bd44556d -size 50586 +oid sha256:2411212e87f95415e0b4ed6732387922faed891cde5e78fa9f0691a185873129 +size 51026 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_5,NEXUS_5,1.0,en].png index 040fa6c12a..5fe74737cf 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewDark_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c336a9cbc803eb314aa9333719d813ae64994a75344aa363b25aee8d52551b21 -size 48928 +oid sha256:255560f478c093f4ca7d021646f4cf9064c16b420d5eeec7cbce97d26c751d92 +size 49540 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_0,NEXUS_5,1.0,en].png index a178b31231..caf6d5a3da 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d7148068516b5e3299cce2924ea3cb54c8f1ddd6378e48b0fa746217a547d22 -size 52940 +oid sha256:a384aa84ae514aa4989d4b4a5ef8048cb8810f1f37056137c77fd0ee798a0bab +size 53941 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_1,NEXUS_5,1.0,en].png index f4a3738cf8..93163a476e 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8aaf2c70473711a24bf587ff524fd71d43c2610ade1c3d4ff2013705d5670b3 -size 54314 +oid sha256:a48badf15c59ab5c42fc7cc200971f00cefa7838461ad0cb59537c2b4065bfff +size 55409 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_2,NEXUS_5,1.0,en].png index f40d649d64..1ff65d3301 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:566d5b197091d4b740e1241eab54017fd31e65f570def4b8c24ade19ea78ccc8 -size 53254 +oid sha256:88a498b9070653513b3e27aa2b597367ee85c8526c6e4c3712a4cb0c06e5313b +size 54353 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_4,NEXUS_5,1.0,en].png index 4e0d1ce4bc..be91f54717 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_4,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_4,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8353077959916fa85046d3a81d1479d552748635f1286bb8bacc6d922ba30f28 -size 55312 +oid sha256:bf493a9cb7325b4f68924abd86864b37c72226afcf1c4179a06c17e923cfd0ae +size 55837 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_5,NEXUS_5,1.0,en].png index d2ac508bf8..432e114fd5 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_5,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_null_MessagesViewLight_0_null_5,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a84dabfdcbaff8de5c30472676dee2cee8dc0f77469f2802dfc796a87bc10dff -size 50328 +oid sha256:8ac3dbf36380fa273d82ebe8836c525bbeee004d6348822d965b216f9bcdb920 +size 51335 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-D-1_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-D-1_2_null,NEXUS_5,1.0,en].png index 4e97d85b18..b1144213da 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-D-1_2_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-D-1_2_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8c4dfcbfb6c97b9bb8a33df6fd1ef57ef436b2c7e7e9bfa5d6a59fcae2515be -size 14140 +oid sha256:ce08e8c81d5494d2d803da43612a137df881e359646878b1d38cdfe77e0857ff +size 13829 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-N-1_3_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-N-1_3_null,NEXUS_5,1.0,en].png index 4fe86c030b..62264fcf27 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-N-1_3_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerEdit-N-1_3_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4fd3c3f554bd668552863a6f7cd3b43cd15cf4a29b2fd67265e3a9a8a05d4258 -size 13216 +oid sha256:3c2ba0ac13c81c707f0d00e3ff69737163dfc450a3d21aa468dd0b34deeeb7e1 +size 12916 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-D-2_3_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-D-2_3_null,NEXUS_5,1.0,en].png index 26c038b0a4..3e872eda82 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-D-2_3_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-D-2_3_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:049d237aa3e2ba18df462caca17d059fa47e25de9bee8f1fbfd310106a1de07c -size 81319 +oid sha256:f15bc37e2a3d052e8d79e663f2c03223c27ddff3c02cd69f95a140c33fc6952c +size 79320 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-N-2_4_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-N-2_4_null,NEXUS_5,1.0,en].png index 0e609116be..8bd9e09c69 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-N-2_4_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerReply-N-2_4_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d45e4a53b9280b295b731ac1287ea31a8c2b653e0ae5dedd82fd2c0a6c2078ba -size 78323 +oid sha256:6aa9b8849a2bbd7b487de6fee7fd355300091bc14d647973c4beea02e6009ac4 +size 76465 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-D-0_1_null,NEXUS_5,1.0,en].png index ef4d9ab56c..8afc6fc94a 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-D-0_1_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-D-0_1_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a817f9c6e2d9823dc7f4d20669c80a3cf8f39e6a4468d7dde7159cb7920f20fd -size 35134 +oid sha256:501734b297cec566a008d3492690cf75b46ab68e662d2835a0b195fd0740c13f +size 42912 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-N-0_2_null,NEXUS_5,1.0,en].png index 9b3ff03b0b..de57e08951 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-N-0_2_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerSimple-N-0_2_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a67d75e43fff35eaa3fff9c17245738b16cb7687f6915c1857a2ba85a61037bf -size 33576 +oid sha256:ff69ed7056380df3279a39b7544bc1887d2e455b43fb069b547e32cf5c92dc0f +size 40229 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index cf29033d88..b92e02f670 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -45,7 +45,7 @@ ] }, { - "name": ":libraries:textcomposer", + "name": ":libraries:textcomposer:impl", "includeRegex": [ "rich_text_editor_.*" ]