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..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,12 +9,21 @@ 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( val attachment: Attachment, 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/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 718f9cfbe4..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,13 +12,19 @@ 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 open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence 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"))), @@ -27,11 +33,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.Attachment(state.allowCaption), + 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..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,6 +436,7 @@ class MessageComposerPresenter @Inject constructor( // Reset composer right away resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) when (capturedMode) { + is MessageComposerMode.Attachment, 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.Attachment, 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..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 @@ -125,62 +126,74 @@ fun TextComposer( val composerOptionsButton: @Composable () -> Unit = remember { @Composable { - ComposerOptionsButton( - modifier = Modifier - .size(48.dp), - onClick = onAddAttachment - ) + if (composerMode is MessageComposerMode.Attachment) { + 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 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) { + 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 { - TextInput( - state = state.richTextEditorState, - subcomposing = subcomposing, - placeholder = placeholder, + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) + TextInputBox( 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, + 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() + val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, @@ -519,7 +532,7 @@ private fun aTextEditorStateRichList() = persistentListOf( internal fun TextComposerSimplePreview() = ElementPreview { PreviewColumn( items = aTextEditorStateMarkdownList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -534,7 +547,7 @@ internal fun TextComposerSimplePreview() = ElementPreview { internal fun TextComposerFormattingPreview() = ElementPreview { PreviewColumn( items = aTextEditorStateRichList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -550,7 +563,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview { internal fun TextComposerEditPreview() = ElementPreview { PreviewColumn( items = aTextEditorStateRichList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -565,7 +578,7 @@ internal fun TextComposerEditPreview() = ElementPreview { internal fun MarkdownTextComposerEditPreview() = ElementPreview { PreviewColumn( items = aTextEditorStateMarkdownList() - ) { textEditorState -> + ) { _, textEditorState -> ATextComposer( state = textEditorState, voiceMessageState = VoiceMessageState.Idle, @@ -580,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, @@ -592,6 +605,22 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider } } +@PreviewsDayNight +@Composable +internal fun TextComposerCaptionPreview() = ElementPreview { + val list = aTextEditorStateMarkdownList() + PreviewColumn( + items = (list + aTextEditorStateMarkdown(initialText = "NO_CAPTION", initialFocus = true)).toPersistentList() + ) { index, textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Attachment(allowCaption = index < list.size), + enableVoiceMessages = false, + ) + } +} + @PreviewsDayNight @Composable internal fun TextComposerVoicePreview() = ElementPreview { @@ -623,7 +652,7 @@ internal fun TextComposerVoicePreview() = ElementPreview { playbackProgress = 0.0f ), ) - ) { voiceMessageState -> + ) { _, voiceMessageState -> ATextComposer( state = aTextEditorStateRich(initialFocus = true), voiceMessageState = voiceMessageState, @@ -636,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 ef96000d2b..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,6 +18,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId sealed interface MessageComposerMode { data object Normal : MessageComposerMode + data class Attachment(val allowCaption: Boolean) : 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 Attachment -> 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", 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