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

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