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 47d7eac1ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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.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<AttachmentsPreviewState> {
@AssistedFactory
interface Factory {
@ -44,11 +50,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mutableStateOf<SendActionState>(SendActionState.Idle)
}
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
TextEditorState.Markdown(markdownTextEditorState)
)
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(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<SendActionState>,
) = 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<SendActionState>,
) = 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(

View file

@ -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 {

View file

@ -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<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState>
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<Attachment
fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(),
sendActionState: SendActionState = SendActionState.Idle
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
sendActionState = sendActionState,
textEditorState = textEditorState,
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.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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
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.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.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.preview.ElementPreviewDark
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.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.wysiwyg.display.TextDisplay
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentsPreviewView(
state: AttachmentsPreviewState,
@ -61,11 +68,23 @@ fun AttachmentsPreviewView(
}
}
Scaffold(modifier) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = onDismiss,
)
},
title = {},
)
}
) {
AttachmentPreviewContent(
attachment = state.attachment,
state = state,
onSendClick = ::postSendAttachment,
onDismiss = onDismiss
)
}
AttachmentSendStateView(
@ -106,21 +125,19 @@ private fun AttachmentSendStateView(
@Composable
private fun AttachmentPreviewContent(
attachment: Attachment,
state: AttachmentsPreviewState,
onSendClick: () -> 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.

View file

@ -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) }

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.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<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
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,
)
}
}

View file

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

View file

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

View file

@ -445,22 +445,22 @@ class RustMatrixRoom(
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
body: String?,
formattedBody: String?,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
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<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> {

View file

@ -326,8 +326,8 @@ class RustTimeline(
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
body: String?,
formattedBody: String?,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> {
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<MediaUploadHandler> {
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,

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_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"

View file

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

View file

@ -131,15 +131,15 @@ class FakeTimeline(
file: File,
thumbnailFile: File?,
imageInfo: ImageInfo,
body: String?,
formattedBody: String?,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<MediaUploadHandler> = 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<MediaUploadHandler> = sendVideoLambda(
file,
thumbnailFile,
videoInfo,
body,
formattedBody,
caption,
formattedCaption,
progressCallback
)

View file

@ -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
)
}

View file

@ -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,
)
)
)
}
}

View file

@ -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) {
@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()
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 <T> PreviewColumn(
items: ImmutableList<T>,
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)
}
}
}

View file

@ -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
}

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a42e8f411d6e54656104cf9472a00713b150ee9978b8bde26b22acd858478bfc
size 84755
oid sha256:9be7c12e6de6bd2975f11ff06eab1b6fa973edcda0ca90c93eed164cb1d6bf18
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