Send caption with media
This commit is contained in:
parent
17ea2aa5dc
commit
223eae9602
19 changed files with 301 additions and 76 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ package io.element.android.features.messages.impl.attachments.preview
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
|
||||
data class AttachmentsPreviewState(
|
||||
val attachment: Attachment,
|
||||
val sendActionState: SendActionState,
|
||||
val textEditorState: TextEditorState,
|
||||
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
|||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
|
||||
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
|
||||
override val values: Sequence<AttachmentsPreviewState>
|
||||
|
|
@ -27,11 +29,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 = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.Caption,
|
||||
onRequestFocus = {},
|
||||
onSendMessage = onSendClick,
|
||||
showTextFormatting = false,
|
||||
onResetComposerMode = {},
|
||||
onAddAttachment = {},
|
||||
onDismissTextFormatting = {},
|
||||
enableVoiceMessages = false,
|
||||
onVoiceRecorderEvent = {},
|
||||
onVoicePlayerEvent = {},
|
||||
onSendVoiceMessage = {},
|
||||
onDeleteVoiceMessage = {},
|
||||
onReceiveSuggestion = {},
|
||||
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
|
||||
onError = {},
|
||||
onTyping = {},
|
||||
onSelectRichContent = {},
|
||||
)
|
||||
}
|
||||
|
||||
// Only preview in dark, dark theme is forced on the Node.
|
||||
|
|
|
|||
|
|
@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
// Reset composer right away
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Caption,
|
||||
is MessageComposerMode.Normal -> room.sendMessage(
|
||||
body = message.markdown,
|
||||
htmlBody = message.html,
|
||||
|
|
@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
): ComposerDraft? {
|
||||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
|
||||
val draftType = when (val mode = messageComposerContext.composerMode) {
|
||||
is MessageComposerMode.Caption,
|
||||
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
|
||||
is MessageComposerMode.Edit -> {
|
||||
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,16 +125,22 @@ fun TextComposer(
|
|||
|
||||
val composerOptionsButton: @Composable () -> Unit = remember {
|
||||
@Composable {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
if (composerMode == MessageComposerMode.Caption) {
|
||||
Spacer(modifier = Modifier.width(9.dp))
|
||||
} else {
|
||||
ComposerOptionsButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
onClick = onAddAttachment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val placeholder = if (composerMode.inThread) {
|
||||
stringResource(id = CommonStrings.action_reply_in_thread)
|
||||
} else if (composerMode == MessageComposerMode.Caption) {
|
||||
stringResource(id = R.string.rich_text_editor_composer_caption_placeholder)
|
||||
} else {
|
||||
stringResource(id = R.string.rich_text_editor_composer_placeholder)
|
||||
}
|
||||
|
|
@ -180,7 +186,7 @@ fun TextComposer(
|
|||
}
|
||||
}
|
||||
|
||||
val canSendMessage = markdown.isNotBlank()
|
||||
val canSendMessage = markdown.isNotBlank() || composerMode == MessageComposerMode.Caption
|
||||
val sendButton = @Composable {
|
||||
SendButton(
|
||||
canSendMessage = canSendMessage,
|
||||
|
|
@ -592,6 +598,21 @@ internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider
|
|||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerCaptionPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview {
|
||||
PreviewColumn(
|
||||
items = aTextEditorStateMarkdownList()
|
||||
) { textEditorState ->
|
||||
ATextComposer(
|
||||
state = textEditorState,
|
||||
voiceMessageState = VoiceMessageState.Idle,
|
||||
composerMode = MessageComposerMode.Caption,
|
||||
enableVoiceMessages = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TextComposerVoicePreview() = ElementPreview {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.eventId
|
|||
sealed interface MessageComposerMode {
|
||||
data object Normal : MessageComposerMode
|
||||
|
||||
data object Caption : MessageComposerMode
|
||||
|
||||
sealed interface Special : MessageComposerMode
|
||||
|
||||
data class Edit(
|
||||
|
|
@ -34,7 +36,8 @@ sealed interface MessageComposerMode {
|
|||
|
||||
val relatedEventId: EventId?
|
||||
get() = when (this) {
|
||||
is Normal -> null
|
||||
is Normal,
|
||||
is Caption -> null
|
||||
is Edit -> eventOrTransactionId.eventId
|
||||
is Reply -> eventId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ class KonsistPreviewTest {
|
|||
"SasEmojisPreview",
|
||||
"SecureBackupSetupViewChangePreview",
|
||||
"SelectedUserCannotRemovePreview",
|
||||
"TextComposerCaptionPreview",
|
||||
"TextComposerEditPreview",
|
||||
"TextComposerFormattingPreview",
|
||||
"TextComposerLinkDialogCreateLinkPreview",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue