Merge pull request #3803 from element-hq/feature/bma/sendCaption

Send caption with image and video
This commit is contained in:
Benoit Marty 2024-11-06 09:13:34 +01:00 committed by GitHub
commit dd8de20147
27 changed files with 384 additions and 128 deletions

View file

@ -9,16 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.ProgressCallback 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.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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -30,6 +35,7 @@ import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor( class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment, @Assisted private val attachment: Attachment,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder,
) : Presenter<AttachmentsPreviewState> { ) : Presenter<AttachmentsPreviewState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -44,11 +50,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mutableStateOf<SendActionState>(SendActionState.Idle) mutableStateOf<SendActionState>(SendActionState.Idle)
} }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
TextEditorState.Markdown(markdownTextEditorState)
)
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (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 -> { AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let { ongoingSendAttachmentJob.value?.let {
it.cancel() it.cancel()
@ -62,18 +81,21 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
return AttachmentsPreviewState( return AttachmentsPreviewState(
attachment = attachment, attachment = attachment,
sendActionState = sendActionState.value, sendActionState = sendActionState.value,
textEditorState = textEditorState,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.sendAttachment( private fun CoroutineScope.sendAttachment(
attachment: Attachment, attachment: Attachment,
caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = launch { ) = launch {
when (attachment) { when (attachment) {
is Attachment.Media -> { is Attachment.Media -> {
sendMedia( sendMedia(
mediaAttachment = attachment, mediaAttachment = attachment,
caption = caption,
sendActionState = sendActionState, sendActionState = sendActionState,
) )
} }
@ -82,6 +104,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private suspend fun sendMedia( private suspend fun sendMedia(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = runCatching { ) = runCatching {
val context = coroutineContext val context = coroutineContext
@ -96,6 +119,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaSender.sendMedia( mediaSender.sendMedia(
uri = mediaAttachment.localMedia.uri, uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType, mimeType = mediaAttachment.localMedia.info.mimeType,
caption = caption,
progressCallback = progressCallback progressCallback = progressCallback
).getOrThrow() ).getOrThrow()
}.fold( }.fold(

View file

@ -9,12 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment 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( data class AttachmentsPreviewState(
val attachment: Attachment, val attachment: Attachment,
val sendActionState: SendActionState, val sendActionState: SendActionState,
val textEditorState: TextEditorState,
val eventSink: (AttachmentsPreviewEvents) -> Unit val eventSink: (AttachmentsPreviewEvents) -> Unit
) ) {
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
it.isMimeTypeImage() || it.isMimeTypeVideo()
}.orFalse()
}
@Immutable @Immutable
sealed interface SendActionState { sealed interface SendActionState {

View file

@ -12,13 +12,19 @@ import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment 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.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo 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.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo 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<AttachmentsPreviewState> { open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState> override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf( get() = sequenceOf(
anAttachmentsPreviewState(), anAttachmentsPreviewState(),
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()), anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
@ -27,11 +33,13 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
fun anAttachmentsPreviewState( fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(), mediaInfo: MediaInfo = anImageMediaInfo(),
sendActionState: SendActionState = SendActionState.Idle textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
) = AttachmentsPreviewState( ) = AttachmentsPreviewState(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo), localMedia = LocalMedia("file://path".toUri(), mediaInfo),
), ),
sendActionState = sendActionState, sendActionState = sendActionState,
textEditorState = textEditorState,
eventSink = {} eventSink = {}
) )

View file

@ -9,37 +9,44 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.display.TextDisplay
import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.rememberZoomableState
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
state: AttachmentsPreviewState, state: AttachmentsPreviewState,
@ -61,11 +68,23 @@ fun AttachmentsPreviewView(
} }
} }
Scaffold(modifier) { Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = onDismiss,
)
},
title = {},
)
}
) {
AttachmentPreviewContent( AttachmentPreviewContent(
attachment = state.attachment, state = state,
onSendClick = ::postSendAttachment, onSendClick = ::postSendAttachment,
onDismiss = onDismiss
) )
} }
AttachmentSendStateView( AttachmentSendStateView(
@ -106,21 +125,19 @@ private fun AttachmentSendStateView(
@Composable @Composable
private fun AttachmentPreviewContent( private fun AttachmentPreviewContent(
attachment: Attachment, state: AttachmentsPreviewState,
onSendClick: () -> Unit, onSendClick: () -> Unit,
onDismiss: () -> Unit,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding(), .navigationBarsPadding(),
contentAlignment = Alignment.BottomCenter
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when (attachment) { when (val attachment = state.attachment) {
is Attachment.Media -> { is Attachment.Media -> {
val localMediaViewState = rememberLocalMediaViewState( val localMediaViewState = rememberLocalMediaViewState(
zoomableState = rememberZoomableState( zoomableState = rememberZoomableState(
@ -137,27 +154,46 @@ private fun AttachmentPreviewContent(
} }
} }
AttachmentsPreviewBottomActions( AttachmentsPreviewBottomActions(
onCancelClick = onDismiss, state = state,
onSendClick = onSendClick, onSendClick = onSendClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = 0.7f)) .background(ElementTheme.colors.bgCanvasDefault)
.padding(horizontal = 24.dp) .height(IntrinsicSize.Min)
.defaultMinSize(minHeight = 80.dp) .align(Alignment.BottomCenter)
.imePadding(),
) )
} }
} }
@Composable @Composable
private fun AttachmentsPreviewBottomActions( private fun AttachmentsPreviewBottomActions(
onCancelClick: () -> Unit, state: AttachmentsPreviewState,
onSendClick: () -> Unit, onSendClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
ButtonRowMolecule(modifier = modifier) { TextComposer(
TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick) modifier = modifier,
TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick) 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. // Only preview in dark, dark theme is forced on the Node.

View file

@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor(
// Reset composer right away // Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> room.sendMessage( is MessageComposerMode.Normal -> room.sendMessage(
body = message.markdown, body = message.markdown,
htmlBody = message.html, htmlBody = message.html,
@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor(
): ComposerDraft? { ): ComposerDraft? {
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false) val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
val draftType = when (val mode = messageComposerContext.composerMode) { val draftType = when (val mode = messageComposerContext.composerMode) {
is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }

View file

@ -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.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.matrix.api.core.ProgressCallback 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.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.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.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.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender 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.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File import java.io.File
@RunWith(RobolectricTestRunner::class)
class AttachmentsPreviewPresenterTest { class AttachmentsPreviewPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private val mediaPreProcessor = FakeMediaPreProcessor()
private val mockMediaUrl: Uri = mockk("localMediaUri") private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test @Test
@ -75,6 +84,80 @@ class AttachmentsPreviewPresenterTest {
} }
} }
@Test
fun `present - send image with caption success scenario`() = runTest {
val sendImageResult =
lambdaRecorder<File, File?, ImageInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
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<File, File?, VideoInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
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 @Test
fun `present - send media failure scenario`() = runTest { fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null) val failure = MediaPreProcessor.Failure(null)
@ -121,11 +204,14 @@ class AttachmentsPreviewPresenterTest {
localMedia: LocalMedia = aLocalMedia( localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl, uri = mockMediaUrl,
), ),
room: MatrixRoom = FakeMatrixRoom() room: MatrixRoom = FakeMatrixRoom(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()) mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder,
) )
} }
} }

View file

@ -132,8 +132,8 @@ interface MatrixRoom : Closeable {
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>
@ -141,8 +141,8 @@ interface MatrixRoom : Closeable {
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>

View file

@ -75,8 +75,8 @@ interface Timeline : AutoCloseable {
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>
@ -84,8 +84,8 @@ interface Timeline : AutoCloseable {
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>

View file

@ -445,22 +445,22 @@ class RustMatrixRoom(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback?, progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> { ): Result<MediaUploadHandler> {
return liveTimeline.sendImage(file, thumbnailFile, imageInfo, body, formattedBody, progressCallback) return liveTimeline.sendImage(file, thumbnailFile, imageInfo, caption, formattedCaption, progressCallback)
} }
override suspend fun sendVideo( override suspend fun sendVideo(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback?, progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> { ): Result<MediaUploadHandler> {
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<MediaUploadHandler> { override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<MediaUploadHandler> {

View file

@ -326,8 +326,8 @@ class RustTimeline(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback?, progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> { ): Result<MediaUploadHandler> {
return sendAttachment(listOfNotNull(file, thumbnailFile)) { return sendAttachment(listOfNotNull(file, thumbnailFile)) {
@ -335,8 +335,8 @@ class RustTimeline(
url = file.path, url = file.path,
thumbnailUrl = thumbnailFile?.path, thumbnailUrl = thumbnailFile?.path,
imageInfo = imageInfo.map(), imageInfo = imageInfo.map(),
caption = body, caption = caption,
formattedCaption = formattedBody?.let { formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html) FormattedBody(body = it, format = MessageFormat.Html)
}, },
storeInCache = true, storeInCache = true,
@ -349,8 +349,8 @@ class RustTimeline(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback?, progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> { ): Result<MediaUploadHandler> {
return sendAttachment(listOfNotNull(file, thumbnailFile)) { return sendAttachment(listOfNotNull(file, thumbnailFile)) {
@ -358,8 +358,8 @@ class RustTimeline(
url = file.path, url = file.path,
thumbnailUrl = thumbnailFile?.path, thumbnailUrl = thumbnailFile?.path,
videoInfo = videoInfo.map(), videoInfo = videoInfo.map(),
caption = body, caption = caption,
formattedCaption = formattedBody?.let { formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html) FormattedBody(body = it, format = MessageFormat.Html)
}, },
storeInCache = true, storeInCache = true,

View file

@ -61,6 +61,7 @@ const val A_ROOM_RAW_NAME = "A room raw name"
const val A_MESSAGE = "Hello world!" const val A_MESSAGE = "Hello world!"
const val A_REPLY = "OK, I'll be there!" const val A_REPLY = "OK, I'll be there!"
const val ANOTHER_MESSAGE = "Hello universe!" const val ANOTHER_MESSAGE = "Hello universe!"
const val A_CAPTION = "A media caption"
const val A_REDACTION_REASON = "A redaction reason" const val A_REDACTION_REASON = "A redaction reason"

View file

@ -321,8 +321,8 @@ class FakeMatrixRoom(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = simulateLongTask { ): Result<MediaUploadHandler> = simulateLongTask {
simulateSendMediaProgress(progressCallback) simulateSendMediaProgress(progressCallback)
@ -330,8 +330,8 @@ class FakeMatrixRoom(
file, file,
thumbnailFile, thumbnailFile,
imageInfo, imageInfo,
body, caption,
formattedBody, formattedCaption,
progressCallback, progressCallback,
) )
} }
@ -340,8 +340,8 @@ class FakeMatrixRoom(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = simulateLongTask { ): Result<MediaUploadHandler> = simulateLongTask {
simulateSendMediaProgress(progressCallback) simulateSendMediaProgress(progressCallback)
@ -349,8 +349,8 @@ class FakeMatrixRoom(
file, file,
thumbnailFile, thumbnailFile,
videoInfo, videoInfo,
body, caption,
formattedBody, formattedCaption,
progressCallback, progressCallback,
) )
} }

View file

@ -131,15 +131,15 @@ class FakeTimeline(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback?, progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendImageLambda( ): Result<MediaUploadHandler> = sendImageLambda(
file, file,
thumbnailFile, thumbnailFile,
imageInfo, imageInfo,
body, caption,
formattedBody, formattedCaption,
progressCallback progressCallback
) )
@ -158,15 +158,15 @@ class FakeTimeline(
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback?, progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = sendVideoLambda( ): Result<MediaUploadHandler> = sendVideoLambda(
file, file,
thumbnailFile, thumbnailFile,
videoInfo, videoInfo,
body, caption,
formattedBody, formattedCaption,
progressCallback progressCallback
) )

View file

@ -106,8 +106,8 @@ class MediaSender @Inject constructor(
file = uploadInfo.file, file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile, thumbnailFile = uploadInfo.thumbnailFile,
imageInfo = uploadInfo.imageInfo, imageInfo = uploadInfo.imageInfo,
body = caption, caption = caption,
formattedBody = formattedCaption, formattedCaption = formattedCaption,
progressCallback = progressCallback progressCallback = progressCallback
) )
} }
@ -116,8 +116,8 @@ class MediaSender @Inject constructor(
file = uploadInfo.file, file = uploadInfo.file,
thumbnailFile = uploadInfo.thumbnailFile, thumbnailFile = uploadInfo.thumbnailFile,
videoInfo = uploadInfo.videoInfo, videoInfo = uploadInfo.videoInfo,
body = caption, caption = caption,
formattedBody = formattedCaption, formattedCaption = formattedCaption,
progressCallback = progressCallback progressCallback = progressCallback
) )
} }

View file

@ -11,6 +11,8 @@ import android.net.Uri
import io.element.android.libraries.core.mimetype.MimeTypes 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.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo 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.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.tests.testutils.simulateLongTask 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,
)
)
)
}
} }

View file

@ -72,6 +72,7 @@ import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import uniffi.wysiwyg_composer.MenuAction import uniffi.wysiwyg_composer.MenuAction
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -125,62 +126,74 @@ fun TextComposer(
val composerOptionsButton: @Composable () -> Unit = remember { val composerOptionsButton: @Composable () -> Unit = remember {
@Composable { @Composable {
ComposerOptionsButton( if (composerMode is MessageComposerMode.Attachment) {
modifier = Modifier Spacer(modifier = Modifier.width(9.dp))
.size(48.dp), } else {
onClick = onAddAttachment ComposerOptionsButton(
) modifier = Modifier
.size(48.dp),
onClick = onAddAttachment
)
}
} }
} }
val placeholder = if (composerMode.inThread) { val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread) stringResource(id = CommonStrings.action_reply_in_thread)
} else if (composerMode is MessageComposerMode.Attachment) {
stringResource(id = R.string.rich_text_editor_composer_caption_placeholder)
} else { } else {
stringResource(id = R.string.rich_text_editor_composer_placeholder) stringResource(id = R.string.rich_text_editor_composer_placeholder)
} }
val textInput: @Composable () -> Unit = when (state) { val textInput: @Composable () -> Unit = if ((composerMode as? MessageComposerMode.Attachment)?.allowCaption == false) {
is TextEditorState.Rich -> { {
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { // No text input when in attachment mode and caption not allowed.
@Composable { }
TextInput( } else {
state = state.richTextEditorState, when (state) {
subcomposing = subcomposing, is TextEditorState.Rich -> {
placeholder = placeholder, remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
composerMode = composerMode, @Composable {
onResetComposerMode = onResetComposerMode, TextInput(
resolveMentionDisplay = resolveMentionDisplay, state = state.richTextEditorState,
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") }, subcomposing = subcomposing,
onError = onError, placeholder = placeholder,
onTyping = onTyping, composerMode = composerMode,
onSelectRichContent = onSelectRichContent, onResetComposerMode = onResetComposerMode,
) resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = { resolveMentionDisplay("@room", "#") },
onError = onError,
onTyping = onTyping,
onSelectRichContent = onSelectRichContent,
)
}
} }
} }
} is TextEditorState.Markdown -> {
is TextEditorState.Markdown -> { @Composable {
@Composable { val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus())
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) TextInputBox(
TextInputBox( composerMode = composerMode,
composerMode = composerMode, onResetComposerMode = onResetComposerMode,
onResetComposerMode = onResetComposerMode, placeholder = placeholder,
placeholder = placeholder, showPlaceholder = { state.state.text.value().isEmpty() },
showPlaceholder = { state.state.text.value().isEmpty() },
subcomposing = subcomposing,
) {
MarkdownTextInput(
state = state.state,
subcomposing = subcomposing, subcomposing = subcomposing,
onTyping = onTyping, ) {
onReceiveSuggestion = onReceiveSuggestion, MarkdownTextInput(
richTextEditorStyle = style, state = state.state,
onSelectRichContent = onSelectRichContent, 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 { val sendButton = @Composable {
SendButton( SendButton(
canSendMessage = canSendMessage, canSendMessage = canSendMessage,
@ -519,7 +532,7 @@ private fun aTextEditorStateRichList() = persistentListOf(
internal fun TextComposerSimplePreview() = ElementPreview { internal fun TextComposerSimplePreview() = ElementPreview {
PreviewColumn( PreviewColumn(
items = aTextEditorStateMarkdownList() items = aTextEditorStateMarkdownList()
) { textEditorState -> ) { _, textEditorState ->
ATextComposer( ATextComposer(
state = textEditorState, state = textEditorState,
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
@ -534,7 +547,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
internal fun TextComposerFormattingPreview() = ElementPreview { internal fun TextComposerFormattingPreview() = ElementPreview {
PreviewColumn( PreviewColumn(
items = aTextEditorStateRichList() items = aTextEditorStateRichList()
) { textEditorState -> ) { _, textEditorState ->
ATextComposer( ATextComposer(
state = textEditorState, state = textEditorState,
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
@ -550,7 +563,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
internal fun TextComposerEditPreview() = ElementPreview { internal fun TextComposerEditPreview() = ElementPreview {
PreviewColumn( PreviewColumn(
items = aTextEditorStateRichList() items = aTextEditorStateRichList()
) { textEditorState -> ) { _, textEditorState ->
ATextComposer( ATextComposer(
state = textEditorState, state = textEditorState,
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
@ -565,7 +578,7 @@ internal fun TextComposerEditPreview() = ElementPreview {
internal fun MarkdownTextComposerEditPreview() = ElementPreview { internal fun MarkdownTextComposerEditPreview() = ElementPreview {
PreviewColumn( PreviewColumn(
items = aTextEditorStateMarkdownList() items = aTextEditorStateMarkdownList()
) { textEditorState -> ) { _, textEditorState ->
ATextComposer( ATextComposer(
state = textEditorState, state = textEditorState,
voiceMessageState = VoiceMessageState.Idle, voiceMessageState = VoiceMessageState.Idle,
@ -580,7 +593,7 @@ internal fun MarkdownTextComposerEditPreview() = ElementPreview {
internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview { internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview {
PreviewColumn( PreviewColumn(
items = aTextEditorStateRichList() items = aTextEditorStateRichList()
) { textEditorState -> ) { _, textEditorState ->
ATextComposer( ATextComposer(
state = textEditorState, state = textEditorState,
voiceMessageState = VoiceMessageState.Idle, 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 @PreviewsDayNight
@Composable @Composable
internal fun TextComposerVoicePreview() = ElementPreview { internal fun TextComposerVoicePreview() = ElementPreview {
@ -623,7 +652,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
playbackProgress = 0.0f playbackProgress = 0.0f
), ),
) )
) { voiceMessageState -> ) { _, voiceMessageState ->
ATextComposer( ATextComposer(
state = aTextEditorStateRich(initialFocus = true), state = aTextEditorStateRich(initialFocus = true),
voiceMessageState = voiceMessageState, voiceMessageState = voiceMessageState,
@ -636,14 +665,14 @@ internal fun TextComposerVoicePreview() = ElementPreview {
@Composable @Composable
private fun <T> PreviewColumn( private fun <T> PreviewColumn(
items: ImmutableList<T>, items: ImmutableList<T>,
view: @Composable (T) -> Unit, view: @Composable (Int, T) -> Unit,
) { ) {
Column { Column {
items.forEach { item -> items.forEachIndexed { index, item ->
Box( Box(
modifier = Modifier.height(IntrinsicSize.Min) modifier = Modifier.height(IntrinsicSize.Min)
) { ) {
view(item) view(index, item)
} }
} }
} }

View file

@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId
sealed interface MessageComposerMode { sealed interface MessageComposerMode {
data object Normal : MessageComposerMode data object Normal : MessageComposerMode
data class Attachment(val allowCaption: Boolean) : MessageComposerMode
sealed interface Special : MessageComposerMode sealed interface Special : MessageComposerMode
data class Edit( data class Edit(
@ -34,7 +36,8 @@ sealed interface MessageComposerMode {
val relatedEventId: EventId? val relatedEventId: EventId?
get() = when (this) { get() = when (this) {
is Normal -> null is Normal,
is Attachment -> null
is Edit -> eventOrTransactionId.eventId is Edit -> eventOrTransactionId.eventId
is Reply -> eventId is Reply -> eventId
} }

View file

@ -36,6 +36,7 @@ sealed interface TextEditorState {
is Rich -> richTextEditorState.hasFocus is Rich -> richTextEditorState.hasFocus
} }
// Note: for test only
suspend fun setHtml(html: String) { suspend fun setHtml(html: String) {
when (this) { when (this) {
is Markdown -> Unit is Markdown -> Unit
@ -43,6 +44,7 @@ sealed interface TextEditorState {
} }
} }
// Note: for test only
suspend fun setMarkdown(text: String) { suspend fun setMarkdown(text: String) {
when (this) { when (this) {
is Markdown -> state.text.update(text, true) is Markdown -> state.text.update(text, true)

View file

@ -101,6 +101,7 @@ class KonsistPreviewTest {
"SasEmojisPreview", "SasEmojisPreview",
"SecureBackupSetupViewChangePreview", "SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview", "SelectedUserCannotRemovePreview",
"TextComposerCaptionPreview",
"TextComposerEditPreview", "TextComposerEditPreview",
"TextComposerFormattingPreview", "TextComposerFormattingPreview",
"TextComposerLinkDialogCreateLinkPreview", "TextComposerLinkDialogCreateLinkPreview",

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3b8b87bd63d1f6febead491dda1cecc0fa9fad0cdca317cf29086fbeec6a9231 oid sha256:899eff34c421e13bf62d6828582c715f87588cfb17c1063aae65baae79a472cf
size 390555 size 394631

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ba4294693123669a24fbdec8093d3c572c639325763c15a1aa53c8f1e4a7659 oid sha256:c41c7438f46e62a4a6f115647040005aba9fd057599fbb17aba844337642d525
size 15230 size 15963

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:16c19e5b2a95da604052a86bd3cdb00d4260ee7539d79e8bb96784e2a2920836 oid sha256:4a9d51bdba64cbd7c453ac177e9c77fb6aa3c611ac16746701b22b024abf3560
size 47019 size 13770

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a42e8f411d6e54656104cf9472a00713b150ee9978b8bde26b22acd858478bfc oid sha256:9be7c12e6de6bd2975f11ff06eab1b6fa973edcda0ca90c93eed164cb1d6bf18
size 84755 size 14841

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9434a531c57fa65c9996f5b5c6254af73ac50433ceeeaa7f1dd1243fe3c3b1c6
size 50355

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c36d5b0d29f1533829f80c33e042bb88648890ad2b629136f8a2af01c511f7a
size 87977

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:391d3741acfd768614a9bc70e948f7fc49b37d75e65591721b922e678d520bac
size 44773

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:713a314ecd36e4d95e5c287ab9a4b5968f5a5090dbb4910d9739e66969fdd424
size 43417