From 223eae96025d06cd5b5388bb9ca1271bd43e630e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 Nov 2024 12:54:11 +0100 Subject: [PATCH 1/5] Send caption with media --- .../preview/AttachmentsPreviewPresenter.kt | 26 +++++- .../preview/AttachmentsPreviewState.kt | 2 + .../AttachmentsPreviewStateProvider.kt | 6 +- .../preview/AttachmentsPreviewView.kt | 80 +++++++++++----- .../MessageComposerPresenter.kt | 2 + .../AttachmentsPreviewPresenterTest.kt | 92 ++++++++++++++++++- .../libraries/matrix/api/room/MatrixRoom.kt | 8 +- .../libraries/matrix/api/timeline/Timeline.kt | 8 +- .../matrix/impl/room/RustMatrixRoom.kt | 12 +-- .../matrix/impl/timeline/RustTimeline.kt | 16 ++-- .../android/libraries/matrix/test/TestData.kt | 1 + .../matrix/test/room/FakeMatrixRoom.kt | 16 ++-- .../matrix/test/timeline/FakeTimeline.kt | 16 ++-- .../libraries/mediaupload/api/MediaSender.kt | 8 +- .../mediaupload/test/FakeMediaPreProcessor.kt | 43 +++++++++ .../libraries/textcomposer/TextComposer.kt | 33 +++++-- .../textcomposer/model/MessageComposerMode.kt | 5 +- .../textcomposer/model/TextEditorState.kt | 2 + .../tests/konsist/KonsistPreviewTest.kt | 1 + 19 files changed, 301 insertions(+), 76 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 42468d66cf..57e2ee3ad8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -9,16 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -30,6 +35,7 @@ import kotlin.coroutines.coroutineContext class AttachmentsPreviewPresenter @AssistedInject constructor( @Assisted private val attachment: Attachment, private val mediaSender: MediaSender, + private val permalinkBuilder: PermalinkBuilder, ) : Presenter { @AssistedFactory interface Factory { @@ -44,11 +50,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( mutableStateOf(SendActionState.Idle) } + val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) + val textEditorState by rememberUpdatedState( + TextEditorState.Markdown(markdownTextEditorState) + ) + val ongoingSendAttachmentJob = remember { mutableStateOf(null) } fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { when (attachmentsPreviewEvents) { - AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState) + is AttachmentsPreviewEvents.SendAttachment -> { + val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + .takeIf { it.isNotEmpty() } + ongoingSendAttachmentJob.value = coroutineScope.sendAttachment( + attachment = attachment, + caption = caption, + sendActionState = sendActionState, + ) + } AttachmentsPreviewEvents.ClearSendState -> { ongoingSendAttachmentJob.value?.let { it.cancel() @@ -62,18 +81,21 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( return AttachmentsPreviewState( attachment = attachment, sendActionState = sendActionState.value, + textEditorState = textEditorState, eventSink = ::handleEvents ) } private fun CoroutineScope.sendAttachment( attachment: Attachment, + caption: String?, sendActionState: MutableState, ) = launch { when (attachment) { is Attachment.Media -> { sendMedia( mediaAttachment = attachment, + caption = caption, sendActionState = sendActionState, ) } @@ -82,6 +104,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( private suspend fun sendMedia( mediaAttachment: Attachment.Media, + caption: String?, sendActionState: MutableState, ) = runCatching { val context = coroutineContext @@ -96,6 +119,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( mediaSender.sendMedia( uri = mediaAttachment.localMedia.uri, mimeType = mediaAttachment.localMedia.info.mimeType, + caption = caption, progressCallback = progressCallback ).getOrThrow() }.fold( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index fc446d60a8..b85ce2c135 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -9,10 +9,12 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.textcomposer.model.TextEditorState data class AttachmentsPreviewState( val attachment: Attachment, val sendActionState: SendActionState, + val textEditorState: TextEditorState, val eventSink: (AttachmentsPreviewEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 718f9cfbe4..a671619cc4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence @@ -27,11 +29,13 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider Unit, - onDismiss: () -> Unit, ) { Box( modifier = Modifier .fillMaxSize() .navigationBarsPadding(), - contentAlignment = Alignment.BottomCenter ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - when (attachment) { + when (val attachment = state.attachment) { is Attachment.Media -> { val localMediaViewState = rememberLocalMediaViewState( zoomableState = rememberZoomableState( @@ -137,27 +154,46 @@ private fun AttachmentPreviewContent( } } AttachmentsPreviewBottomActions( - onCancelClick = onDismiss, + state = state, onSendClick = onSendClick, modifier = Modifier .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.7f)) - .padding(horizontal = 24.dp) - .defaultMinSize(minHeight = 80.dp) + .background(ElementTheme.colors.bgCanvasDefault) + .height(IntrinsicSize.Min) + .align(Alignment.BottomCenter) + .imePadding(), ) } } @Composable private fun AttachmentsPreviewBottomActions( - onCancelClick: () -> Unit, + state: AttachmentsPreviewState, onSendClick: () -> Unit, modifier: Modifier = Modifier ) { - ButtonRowMolecule(modifier = modifier) { - TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick) - TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick) - } + TextComposer( + modifier = modifier, + state = state.textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Caption, + onRequestFocus = {}, + onSendMessage = onSendClick, + showTextFormatting = false, + onResetComposerMode = {}, + onAddAttachment = {}, + onDismissTextFormatting = {}, + enableVoiceMessages = false, + onVoiceRecorderEvent = {}, + onVoicePlayerEvent = {}, + onSendVoiceMessage = {}, + onDeleteVoiceMessage = {}, + onReceiveSuggestion = {}, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + onError = {}, + onTyping = {}, + onSelectRichContent = {}, + ) } // Only preview in dark, dark theme is forced on the Node. 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 cce78d601e..6f430cbaae 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 @@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor( // Reset composer right away resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) when (capturedMode) { + is MessageComposerMode.Caption, is MessageComposerMode.Normal -> room.sendMessage( body = message.markdown, htmlBody = message.html, @@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor( ): ComposerDraft? { val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false) val draftType = when (val mode = messageComposerContext.composerMode) { + is MessageComposerMode.Caption, is MessageComposerMode.Normal -> ComposerDraftType.NewMessage is MessageComposerMode.Edit -> { mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index d0b4b87794..51318454eb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -20,8 +20,13 @@ import io.element.android.features.messages.impl.attachments.preview.SendActionS import io.element.android.features.messages.impl.fixtures.aMediaAttachment import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender @@ -30,19 +35,23 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import java.io.File +@RunWith(RobolectricTestRunner::class) class AttachmentsPreviewPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val mediaPreProcessor = FakeMediaPreProcessor() private val mockMediaUrl: Uri = mockk("localMediaUri") @Test @@ -75,6 +84,80 @@ class AttachmentsPreviewPresenterTest { } } + @Test + fun `present - send image with caption success scenario`() = runTest { + val sendImageResult = + lambdaRecorder> { _, _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + val mediaPreProcessor = FakeMediaPreProcessor().apply { + givenImageResult() + } + val room = FakeMatrixRoom( + sendImageResult = sendImageResult, + ) + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.textEditorState.setMarkdown(A_CAPTION) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) + val successState = awaitItem() + assertThat(successState.sendActionState).isEqualTo(SendActionState.Done) + sendImageResult.assertions().isCalledOnce().with( + any(), + any(), + any(), + value(A_CAPTION), + any(), + any(), + ) + } + } + + @Test + fun `present - send video with caption success scenario`() = runTest { + val sendVideoResult = + lambdaRecorder> { _, _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + val mediaPreProcessor = FakeMediaPreProcessor().apply { + givenVideoResult() + } + val room = FakeMatrixRoom( + sendVideoResult = sendVideoResult, + ) + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.textEditorState.setMarkdown(A_CAPTION) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) + val successState = awaitItem() + assertThat(successState.sendActionState).isEqualTo(SendActionState.Done) + sendVideoResult.assertions().isCalledOnce().with( + any(), + any(), + any(), + value(A_CAPTION), + any(), + any(), + ) + } + } + @Test fun `present - send media failure scenario`() = runTest { val failure = MediaPreProcessor.Failure(null) @@ -121,11 +204,14 @@ class AttachmentsPreviewPresenterTest { localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, ), - room: MatrixRoom = FakeMatrixRoom() + room: MatrixRoom = FakeMatrixRoom(), + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( attachment = aMediaAttachment(localMedia), - mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()) + mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), + permalinkBuilder = permalinkBuilder, ) } } 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 fcc1fd1812..3bbbf6fdd4 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 @@ -132,8 +132,8 @@ interface MatrixRoom : Closeable { file: File, thumbnailFile: File?, imageInfo: ImageInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback? ): Result @@ -141,8 +141,8 @@ interface MatrixRoom : Closeable { file: File, thumbnailFile: File?, videoInfo: VideoInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback? ): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 085c4d49ea..695fe906c5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -75,8 +75,8 @@ interface Timeline : AutoCloseable { file: File, thumbnailFile: File?, imageInfo: ImageInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback? ): Result @@ -84,8 +84,8 @@ interface Timeline : AutoCloseable { file: File, thumbnailFile: File?, videoInfo: VideoInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback? ): 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 31d8ae1f43..a80d092145 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 @@ -445,22 +445,22 @@ class RustMatrixRoom( file: File, thumbnailFile: File?, imageInfo: ImageInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback?, ): Result { - return liveTimeline.sendImage(file, thumbnailFile, imageInfo, body, formattedBody, progressCallback) + return liveTimeline.sendImage(file, thumbnailFile, imageInfo, caption, formattedCaption, progressCallback) } override suspend fun sendVideo( file: File, thumbnailFile: File?, videoInfo: VideoInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback?, ): Result { - return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, body, formattedBody, progressCallback) + return liveTimeline.sendVideo(file, thumbnailFile, videoInfo, caption, formattedCaption, progressCallback) } override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index af597a88ab..019f59bfaf 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -326,8 +326,8 @@ class RustTimeline( file: File, thumbnailFile: File?, imageInfo: ImageInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback?, ): Result { return sendAttachment(listOfNotNull(file, thumbnailFile)) { @@ -335,8 +335,8 @@ class RustTimeline( url = file.path, thumbnailUrl = thumbnailFile?.path, imageInfo = imageInfo.map(), - caption = body, - formattedCaption = formattedBody?.let { + caption = caption, + formattedCaption = formattedCaption?.let { FormattedBody(body = it, format = MessageFormat.Html) }, storeInCache = true, @@ -349,8 +349,8 @@ class RustTimeline( file: File, thumbnailFile: File?, videoInfo: VideoInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback?, ): Result { return sendAttachment(listOfNotNull(file, thumbnailFile)) { @@ -358,8 +358,8 @@ class RustTimeline( url = file.path, thumbnailUrl = thumbnailFile?.path, videoInfo = videoInfo.map(), - caption = body, - formattedCaption = formattedBody?.let { + caption = caption, + formattedCaption = formattedCaption?.let { FormattedBody(body = it, format = MessageFormat.Html) }, storeInCache = true, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index 83a9b8e5dd..4b4bed0762 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -61,6 +61,7 @@ const val A_ROOM_RAW_NAME = "A room raw name" const val A_MESSAGE = "Hello world!" const val A_REPLY = "OK, I'll be there!" const val ANOTHER_MESSAGE = "Hello universe!" +const val A_CAPTION = "A media caption" const val A_REDACTION_REASON = "A redaction reason" 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 24e253311d..4a740c0732 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 @@ -321,8 +321,8 @@ class FakeMatrixRoom( file: File, thumbnailFile: File?, imageInfo: ImageInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback? ): Result = simulateLongTask { simulateSendMediaProgress(progressCallback) @@ -330,8 +330,8 @@ class FakeMatrixRoom( file, thumbnailFile, imageInfo, - body, - formattedBody, + caption, + formattedCaption, progressCallback, ) } @@ -340,8 +340,8 @@ class FakeMatrixRoom( file: File, thumbnailFile: File?, videoInfo: VideoInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback? ): Result = simulateLongTask { simulateSendMediaProgress(progressCallback) @@ -349,8 +349,8 @@ class FakeMatrixRoom( file, thumbnailFile, videoInfo, - body, - formattedBody, + caption, + formattedCaption, progressCallback, ) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index 0395bc2328..ae40a0a51e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -131,15 +131,15 @@ class FakeTimeline( file: File, thumbnailFile: File?, imageInfo: ImageInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback?, ): Result = sendImageLambda( file, thumbnailFile, imageInfo, - body, - formattedBody, + caption, + formattedCaption, progressCallback ) @@ -158,15 +158,15 @@ class FakeTimeline( file: File, thumbnailFile: File?, videoInfo: VideoInfo, - body: String?, - formattedBody: String?, + caption: String?, + formattedCaption: String?, progressCallback: ProgressCallback?, ): Result = sendVideoLambda( file, thumbnailFile, videoInfo, - body, - formattedBody, + caption, + formattedCaption, progressCallback ) diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index a47629d4f2..54f886d302 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -106,8 +106,8 @@ class MediaSender @Inject constructor( file = uploadInfo.file, thumbnailFile = uploadInfo.thumbnailFile, imageInfo = uploadInfo.imageInfo, - body = caption, - formattedBody = formattedCaption, + caption = caption, + formattedCaption = formattedCaption, progressCallback = progressCallback ) } @@ -116,8 +116,8 @@ class MediaSender @Inject constructor( file = uploadInfo.file, thumbnailFile = uploadInfo.thumbnailFile, videoInfo = uploadInfo.videoInfo, - body = caption, - formattedBody = formattedCaption, + caption = caption, + formattedCaption = formattedCaption, progressCallback = progressCallback ) } diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt index 634cb24be7..efb3b77ed8 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -11,6 +11,8 @@ import android.net.Uri import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.tests.testutils.simulateLongTask @@ -61,4 +63,45 @@ class FakeMediaPreProcessor : MediaPreProcessor { ) ) } + + fun givenImageResult() { + givenResult( + Result.success( + MediaUploadInfo.Image( + file = File("image.jpg"), + imageInfo = ImageInfo( + height = 100, + width = 100, + mimetype = MimeTypes.Jpeg, + size = 1000, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = null, + ) + ) + ) + } + + fun givenVideoResult() { + givenResult( + Result.success( + MediaUploadInfo.Video( + file = File("image.jpg"), + videoInfo = VideoInfo( + duration = 1000.seconds, + height = 100, + width = 100, + mimetype = MimeTypes.Mp4, + size = 1000, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = null, + ) + ) + ) + } } 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 7af05baaa4..ca0d71d4d3 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 @@ -125,16 +125,22 @@ fun TextComposer( val composerOptionsButton: @Composable () -> Unit = remember { @Composable { - ComposerOptionsButton( - modifier = Modifier - .size(48.dp), - onClick = onAddAttachment - ) + if (composerMode == MessageComposerMode.Caption) { + Spacer(modifier = Modifier.width(9.dp)) + } else { + ComposerOptionsButton( + modifier = Modifier + .size(48.dp), + onClick = onAddAttachment + ) + } } } val placeholder = if (composerMode.inThread) { stringResource(id = CommonStrings.action_reply_in_thread) + } else if (composerMode == MessageComposerMode.Caption) { + stringResource(id = R.string.rich_text_editor_composer_caption_placeholder) } else { stringResource(id = R.string.rich_text_editor_composer_placeholder) } @@ -180,7 +186,7 @@ fun TextComposer( } } - val canSendMessage = markdown.isNotBlank() + val canSendMessage = markdown.isNotBlank() || composerMode == MessageComposerMode.Caption val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, @@ -592,6 +598,21 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider } } +@PreviewsDayNight +@Composable +internal fun TextComposerCaptionPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview { + PreviewColumn( + items = aTextEditorStateMarkdownList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Caption, + enableVoiceMessages = false, + ) + } +} + @PreviewsDayNight @Composable internal fun TextComposerVoicePreview() = ElementPreview { diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt index ef96000d2b..a729f332b7 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt @@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId sealed interface MessageComposerMode { data object Normal : MessageComposerMode + data object Caption : MessageComposerMode + sealed interface Special : MessageComposerMode data class Edit( @@ -34,7 +36,8 @@ sealed interface MessageComposerMode { val relatedEventId: EventId? get() = when (this) { - is Normal -> null + is Normal, + is Caption -> null is Edit -> eventOrTransactionId.eventId is Reply -> eventId } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt index 88c970d848..b1f7d56d07 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -36,6 +36,7 @@ sealed interface TextEditorState { is Rich -> richTextEditorState.hasFocus } + // Note: for test only suspend fun setHtml(html: String) { when (this) { is Markdown -> Unit @@ -43,6 +44,7 @@ sealed interface TextEditorState { } } + // Note: for test only suspend fun setMarkdown(text: String) { when (this) { is Markdown -> state.text.update(text, true) 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 c00d72fffb..83541624a1 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 @@ -101,6 +101,7 @@ class KonsistPreviewTest { "SasEmojisPreview", "SecureBackupSetupViewChangePreview", "SelectedUserCannotRemovePreview", + "TextComposerCaptionPreview", "TextComposerEditPreview", "TextComposerFormattingPreview", "TextComposerLinkDialogCreateLinkPreview", From 39ab2f848ac2012067428db7593fb4c9dc4eeb7b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 Nov 2024 13:46:01 +0100 Subject: [PATCH 2/5] Fix multiple previews issue. --- .../libraries/textcomposer/TextComposer.kt | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) 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 ca0d71d4d3..47f4b5268f 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 @@ -120,8 +120,8 @@ fun TextComposer( } val layoutModifier = modifier - .fillMaxSize() - .height(IntrinsicSize.Min) + .fillMaxSize() + .height(IntrinsicSize.Min) val composerOptionsButton: @Composable () -> Unit = remember { @Composable { @@ -324,8 +324,8 @@ private fun StandardLayout( if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) { Box( modifier = Modifier - .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) - .size(48.dp), + .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) + .size(48.dp), contentAlignment = Alignment.Center, ) { voiceDeleteButton() @@ -335,8 +335,8 @@ private fun StandardLayout( } Box( modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) ) { voiceRecording() } @@ -349,16 +349,16 @@ private fun StandardLayout( } Box( modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) ) { textInput() } } Box( - Modifier - .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) - .size(48.dp), + Modifier + .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) + .size(48.dp), contentAlignment = Alignment.Center, ) { endButton() @@ -380,8 +380,8 @@ private fun TextFormattingLayout( ) { Box( modifier = Modifier - .weight(1f) - .padding(horizontal = 12.dp) + .weight(1f) + .padding(horizontal = 12.dp) ) { textInput() } @@ -425,11 +425,11 @@ private fun TextInputBox( Column( modifier = Modifier - .clip(roundedCorners) - .border(0.5.dp, borderColor, roundedCorners) - .background(color = bgColor) - .requiredHeightIn(min = 42.dp) - .fillMaxSize(), + .clip(roundedCorners) + .border(0.5.dp, borderColor, roundedCorners) + .background(color = bgColor) + .requiredHeightIn(min = 42.dp) + .fillMaxSize(), ) { if (composerMode is MessageComposerMode.Special) { ComposerModeView( @@ -440,9 +440,9 @@ private fun TextInputBox( val defaultTypography = ElementTheme.typography.fontBodyLgRegular Box( modifier = Modifier - .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp) - // 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), + .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp) + // 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 @@ -488,8 +488,8 @@ private fun TextInput( // This prevents it gaining focus and mutating the state. registerStateUpdates = !subcomposing, modifier = Modifier - .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), resolveMentionDisplay = resolveMentionDisplay, resolveRoomMentionDisplay = resolveRoomMentionDisplay, @@ -600,7 +600,7 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider @PreviewsDayNight @Composable -internal fun TextComposerCaptionPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview { +internal fun TextComposerCaptionPreview() = ElementPreview { PreviewColumn( items = aTextEditorStateMarkdownList() ) { textEditorState -> From 19eb4c8395857ffa8befe41858d187815e268372 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 Nov 2024 14:09:27 +0100 Subject: [PATCH 3/5] Do not allow caption on audio files. Regular files are not previewed, but prevent caption as well there. --- .../preview/AttachmentsPreviewState.kt | 9 +- .../preview/AttachmentsPreviewView.kt | 2 +- .../MessageComposerPresenter.kt | 4 +- .../libraries/textcomposer/TextComposer.kt | 152 +++++++++--------- .../textcomposer/model/MessageComposerMode.kt | 4 +- 5 files changed, 93 insertions(+), 78 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index b85ce2c135..72ea0a2098 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -9,6 +9,9 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.textcomposer.model.TextEditorState data class AttachmentsPreviewState( @@ -16,7 +19,11 @@ data class AttachmentsPreviewState( val sendActionState: SendActionState, val textEditorState: TextEditorState, val eventSink: (AttachmentsPreviewEvents) -> Unit -) +) { + val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let { + it.isMimeTypeImage() || it.isMimeTypeVideo() + }.orFalse() +} @Immutable sealed interface SendActionState { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index ab7a117fdf..2906f26b3c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -176,7 +176,7 @@ private fun AttachmentsPreviewBottomActions( modifier = modifier, state = state.textEditorState, voiceMessageState = VoiceMessageState.Idle, - composerMode = MessageComposerMode.Caption, + composerMode = MessageComposerMode.Attachment(state.allowCaption), onRequestFocus = {}, onSendMessage = onSendClick, showTextFormatting = false, 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 6f430cbaae..6101f48c1f 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 @@ -436,7 +436,7 @@ class MessageComposerPresenter @Inject constructor( // Reset composer right away resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) when (capturedMode) { - is MessageComposerMode.Caption, + is MessageComposerMode.Attachment, is MessageComposerMode.Normal -> room.sendMessage( body = message.markdown, htmlBody = message.html, @@ -606,7 +606,7 @@ class MessageComposerPresenter @Inject constructor( ): ComposerDraft? { val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false) val draftType = when (val mode = messageComposerContext.composerMode) { - is MessageComposerMode.Caption, + is MessageComposerMode.Attachment, is MessageComposerMode.Normal -> ComposerDraftType.NewMessage is MessageComposerMode.Edit -> { mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } 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 47f4b5268f..eca7340219 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 @@ -72,6 +72,7 @@ import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.display.TextDisplay import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import uniffi.wysiwyg_composer.MenuAction import kotlin.time.Duration.Companion.seconds @@ -120,12 +121,12 @@ fun TextComposer( } val layoutModifier = modifier - .fillMaxSize() - .height(IntrinsicSize.Min) + .fillMaxSize() + .height(IntrinsicSize.Min) val composerOptionsButton: @Composable () -> Unit = remember { @Composable { - if (composerMode == MessageComposerMode.Caption) { + if (composerMode is MessageComposerMode.Attachment) { Spacer(modifier = Modifier.width(9.dp)) } else { ComposerOptionsButton( @@ -139,54 +140,60 @@ fun TextComposer( val placeholder = if (composerMode.inThread) { stringResource(id = CommonStrings.action_reply_in_thread) - } else if (composerMode == MessageComposerMode.Caption) { + } else if (composerMode is MessageComposerMode.Attachment) { stringResource(id = R.string.rich_text_editor_composer_caption_placeholder) } else { stringResource(id = R.string.rich_text_editor_composer_placeholder) } - val textInput: @Composable () -> Unit = when (state) { - is TextEditorState.Rich -> { - remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { - @Composable { - TextInput( - state = state.richTextEditorState, - subcomposing = subcomposing, - placeholder = placeholder, - composerMode = composerMode, - onResetComposerMode = onResetComposerMode, - resolveMentionDisplay = resolveMentionDisplay, - resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") }, - onError = onError, - onTyping = onTyping, - onSelectRichContent = onSelectRichContent, - ) + val textInput: @Composable () -> Unit = if ((composerMode as? MessageComposerMode.Attachment)?.allowCaption == false) { + { + // No text input when in attachment mode and caption not allowed. + } + } else { + when (state) { + is TextEditorState.Rich -> { + remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { + @Composable { + TextInput( + state = state.richTextEditorState, + subcomposing = subcomposing, + placeholder = placeholder, + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + resolveMentionDisplay = resolveMentionDisplay, + resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") }, + onError = onError, + onTyping = onTyping, + onSelectRichContent = onSelectRichContent, + ) + } } } - } - 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, + 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, - onTyping = onTyping, - onReceiveSuggestion = onReceiveSuggestion, - richTextEditorStyle = style, - onSelectRichContent = onSelectRichContent, - ) + ) { + MarkdownTextInput( + state = state.state, + subcomposing = subcomposing, + onTyping = onTyping, + onReceiveSuggestion = onReceiveSuggestion, + richTextEditorStyle = style, + onSelectRichContent = onSelectRichContent, + ) + } } } } } - val canSendMessage = markdown.isNotBlank() || composerMode == MessageComposerMode.Caption + val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, @@ -324,8 +331,8 @@ private fun StandardLayout( if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) { Box( modifier = Modifier - .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) - .size(48.dp), + .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) + .size(48.dp), contentAlignment = Alignment.Center, ) { voiceDeleteButton() @@ -335,8 +342,8 @@ private fun StandardLayout( } Box( modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) ) { voiceRecording() } @@ -349,16 +356,16 @@ private fun StandardLayout( } Box( modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) ) { textInput() } } Box( - Modifier - .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) - .size(48.dp), + Modifier + .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) + .size(48.dp), contentAlignment = Alignment.Center, ) { endButton() @@ -380,8 +387,8 @@ private fun TextFormattingLayout( ) { Box( modifier = Modifier - .weight(1f) - .padding(horizontal = 12.dp) + .weight(1f) + .padding(horizontal = 12.dp) ) { textInput() } @@ -425,11 +432,11 @@ private fun TextInputBox( Column( modifier = Modifier - .clip(roundedCorners) - .border(0.5.dp, borderColor, roundedCorners) - .background(color = bgColor) - .requiredHeightIn(min = 42.dp) - .fillMaxSize(), + .clip(roundedCorners) + .border(0.5.dp, borderColor, roundedCorners) + .background(color = bgColor) + .requiredHeightIn(min = 42.dp) + .fillMaxSize(), ) { if (composerMode is MessageComposerMode.Special) { ComposerModeView( @@ -440,9 +447,9 @@ private fun TextInputBox( val defaultTypography = ElementTheme.typography.fontBodyLgRegular Box( modifier = Modifier - .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp) - // 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), + .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp) + // 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 @@ -488,8 +495,8 @@ private fun TextInput( // This prevents it gaining focus and mutating the state. registerStateUpdates = !subcomposing, modifier = Modifier - .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), resolveMentionDisplay = resolveMentionDisplay, resolveRoomMentionDisplay = resolveRoomMentionDisplay, @@ -525,7 +532,7 @@ private fun aTextEditorStateRichList() = persistentListOf( internal fun TextComposerSimplePreview() = ElementPreview { PreviewColumn( items = aTextEditorStateMarkdownList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -540,7 +547,7 @@ internal fun TextComposerSimplePreview() = ElementPreview { internal fun TextComposerFormattingPreview() = ElementPreview { PreviewColumn( items = aTextEditorStateRichList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -556,7 +563,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview { internal fun TextComposerEditPreview() = ElementPreview { PreviewColumn( items = aTextEditorStateRichList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -571,7 +578,7 @@ internal fun TextComposerEditPreview() = ElementPreview { internal fun MarkdownTextComposerEditPreview() = ElementPreview { PreviewColumn( items = aTextEditorStateMarkdownList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -586,7 +593,7 @@ internal fun MarkdownTextComposerEditPreview() = ElementPreview { internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview { PreviewColumn( items = aTextEditorStateRichList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -601,13 +608,14 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider @PreviewsDayNight @Composable internal fun TextComposerCaptionPreview() = ElementPreview { + val list = aTextEditorStateMarkdownList() PreviewColumn( - items = aTextEditorStateMarkdownList() - ) { textEditorState -> + items = (list + aTextEditorStateMarkdown(initialText = "NO_CAPTION", initialFocus = true)).toPersistentList() + ) { index, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, - composerMode = MessageComposerMode.Caption, + composerMode = MessageComposerMode.Attachment(allowCaption = index < list.size), enableVoiceMessages = false, ) } @@ -644,7 +652,7 @@ internal fun TextComposerVoicePreview() = ElementPreview { playbackProgress = 0.0f ), ) - ) { voiceMessageState -> + ) { _, voiceMessageState -> ATextComposer( state = aTextEditorStateRich(initialFocus = true), voiceMessageState = voiceMessageState, @@ -657,14 +665,14 @@ internal fun TextComposerVoicePreview() = ElementPreview { @Composable private fun PreviewColumn( items: ImmutableList, - view: @Composable (T) -> Unit, + view: @Composable (Int, T) -> Unit, ) { Column { - items.forEach { item -> + items.forEachIndexed { index, item -> Box( modifier = Modifier.height(IntrinsicSize.Min) ) { - view(item) + view(index, item) } } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt index a729f332b7..1915359c83 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt @@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId sealed interface MessageComposerMode { data object Normal : MessageComposerMode - data object Caption : MessageComposerMode + data class Attachment(val allowCaption: Boolean) : MessageComposerMode sealed interface Special : MessageComposerMode @@ -37,7 +37,7 @@ sealed interface MessageComposerMode { val relatedEventId: EventId? get() = when (this) { is Normal, - is Caption -> null + is Attachment -> null is Edit -> eventOrTransactionId.eventId is Reply -> eventId } From a17bedc457f53e08c554068a82b3f443c8881881 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 4 Nov 2024 14:24:00 +0100 Subject: [PATCH 4/5] Add more preview. --- .../attachments/preview/AttachmentsPreviewStateProvider.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index a671619cc4..78f3ffc81a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -12,7 +12,9 @@ import androidx.core.net.toUri import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown @@ -21,6 +23,8 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider get() = sequenceOf( anAttachmentsPreviewState(), + anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()), + anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()), anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))), From ddc40857422e28fdb0986c7ba3d02073f24e6ed7 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 4 Nov 2024 13:45:11 +0000 Subject: [PATCH 5/5] Update screenshots --- ...messages.impl.attachments.preview_AttachmentsView_0_en.png | 4 ++-- ...messages.impl.attachments.preview_AttachmentsView_1_en.png | 4 ++-- ...messages.impl.attachments.preview_AttachmentsView_2_en.png | 4 ++-- ...messages.impl.attachments.preview_AttachmentsView_3_en.png | 4 ++-- ...messages.impl.attachments.preview_AttachmentsView_4_en.png | 3 +++ ...messages.impl.attachments.preview_AttachmentsView_5_en.png | 3 +++ .../libraries.textcomposer_TextComposerCaption_Day_0_en.png | 3 +++ .../libraries.textcomposer_TextComposerCaption_Night_0_en.png | 3 +++ 8 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png index 622f3dc9c1..dd26109be0 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3b8b87bd63d1f6febead491dda1cecc0fa9fad0cdca317cf29086fbeec6a9231 -size 390555 +oid sha256:899eff34c421e13bf62d6828582c715f87588cfb17c1063aae65baae79a472cf +size 394631 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png index 438fe60e31..7514170564 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ba4294693123669a24fbdec8093d3c572c639325763c15a1aa53c8f1e4a7659 -size 15230 +oid sha256:c41c7438f46e62a4a6f115647040005aba9fd057599fbb17aba844337642d525 +size 15963 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png index 68bbcc5e4c..b5ca13b59e 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16c19e5b2a95da604052a86bd3cdb00d4260ee7539d79e8bb96784e2a2920836 -size 47019 +oid sha256:4a9d51bdba64cbd7c453ac177e9c77fb6aa3c611ac16746701b22b024abf3560 +size 13770 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png index 8c3ad63622..d9ea161fab 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a42e8f411d6e54656104cf9472a00713b150ee9978b8bde26b22acd858478bfc -size 84755 +oid sha256:9be7c12e6de6bd2975f11ff06eab1b6fa973edcda0ca90c93eed164cb1d6bf18 +size 14841 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png new file mode 100644 index 0000000000..5f643fefc6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9434a531c57fa65c9996f5b5c6254af73ac50433ceeeaa7f1dd1243fe3c3b1c6 +size 50355 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png new file mode 100644 index 0000000000..ac695699f0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c36d5b0d29f1533829f80c33e042bb88648890ad2b629136f8a2af01c511f7a +size 87977 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Day_0_en.png new file mode 100644 index 0000000000..151c86e357 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:391d3741acfd768614a9bc70e948f7fc49b37d75e65591721b922e678d520bac +size 44773 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Night_0_en.png new file mode 100644 index 0000000000..91dfb69902 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerCaption_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:713a314ecd36e4d95e5c287ab9a4b5968f5a5090dbb4910d9739e66969fdd424 +size 43417