Render media captions formatting in the media viewer (#6729)

* Render media captions formatting in the media viewer

* Update screenshots

* Trigger actions

* Remove unused imports and reformat code

---------

Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
bxdxnn
2026-05-08 17:30:32 +03:00
committed by GitHub
parent 4a4b3e07ef
commit 071d98c66b
26 changed files with 182 additions and 22 deletions
@@ -646,6 +646,7 @@ class MessagesFlowNode(
filename = content.filename, filename = content.filename,
fileSize = content.fileSize, fileSize = content.fileSize,
caption = content.caption, caption = content.caption,
formattedCaption = content.formattedCaption,
mimeType = content.mimeType, mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize, formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension, fileExtension = content.fileExtension,
@@ -17,6 +17,7 @@ import kotlinx.parcelize.Parcelize
data class MediaInfo( data class MediaInfo(
val filename: String, val filename: String,
val caption: String?, val caption: String?,
val formattedCaption: CharSequence? = null,
val mimeType: String, val mimeType: String,
val fileSize: Long?, val fileSize: Long?,
val formattedFileSize: String, val formattedFileSize: String,
@@ -33,6 +34,7 @@ data class MediaInfo(
fun anImageMediaInfo( fun anImageMediaInfo(
senderId: UserId? = UserId("@alice:server.org"), senderId: UserId? = UserId("@alice:server.org"),
caption: String? = null, caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null, senderName: String? = null,
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
@@ -40,6 +42,7 @@ fun anImageMediaInfo(
filename = "an image file.jpg", filename = "an image file.jpg",
fileSize = 4 * 1024 * 1024, fileSize = 4 * 1024 * 1024,
caption = caption, caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Jpeg, mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB", formattedFileSize = "4MB",
fileExtension = "jpg", fileExtension = "jpg",
@@ -54,6 +57,7 @@ fun anImageMediaInfo(
fun aVideoMediaInfo( fun aVideoMediaInfo(
caption: String? = null, caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null, senderName: String? = null,
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
@@ -62,6 +66,7 @@ fun aVideoMediaInfo(
filename = "a video file.mp4", filename = "a video file.mp4",
fileSize = 14 * 1024 * 1024, fileSize = 14 * 1024 * 1024,
caption = caption, caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Mp4, mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB", formattedFileSize = "14MB",
fileExtension = "mp4", fileExtension = "mp4",
@@ -77,6 +82,7 @@ fun aVideoMediaInfo(
fun aPdfMediaInfo( fun aPdfMediaInfo(
filename: String = "a pdf file.pdf", filename: String = "a pdf file.pdf",
caption: String? = null, caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null, senderName: String? = null,
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
@@ -84,6 +90,7 @@ fun aPdfMediaInfo(
filename = filename, filename = filename,
fileSize = 23 * 1024 * 1024, fileSize = 23 * 1024 * 1024,
caption = caption, caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Pdf, mimeType = MimeTypes.Pdf,
formattedFileSize = "23MB", formattedFileSize = "23MB",
fileExtension = "pdf", fileExtension = "pdf",
@@ -105,6 +112,7 @@ fun anApkMediaInfo(
filename = "an apk file.apk", filename = "an apk file.apk",
fileSize = 50 * 1024 * 1024, fileSize = 50 * 1024 * 1024,
caption = null, caption = null,
formattedCaption = null,
mimeType = MimeTypes.Apk, mimeType = MimeTypes.Apk,
formattedFileSize = "50MB", formattedFileSize = "50MB",
fileExtension = "apk", fileExtension = "apk",
@@ -120,6 +128,7 @@ fun anApkMediaInfo(
fun anAudioMediaInfo( fun anAudioMediaInfo(
filename: String = "an audio file.mp3", filename: String = "an audio file.mp3",
caption: String? = null, caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null, senderName: String? = null,
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
@@ -129,6 +138,7 @@ fun anAudioMediaInfo(
filename = filename, filename = filename,
fileSize = 7 * 1024 * 1024, fileSize = 7 * 1024 * 1024,
caption = caption, caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Mp3, mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB", formattedFileSize = "7MB",
fileExtension = "mp3", fileExtension = "mp3",
@@ -144,6 +154,7 @@ fun anAudioMediaInfo(
fun aVoiceMediaInfo( fun aVoiceMediaInfo(
filename: String = "a voice file.ogg", filename: String = "a voice file.ogg",
caption: String? = null, caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null, senderName: String? = null,
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
@@ -153,6 +164,7 @@ fun aVoiceMediaInfo(
filename = filename, filename = filename,
fileSize = 3 * 1024 * 1024, fileSize = 3 * 1024 * 1024,
caption = caption, caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Ogg, mimeType = MimeTypes.Ogg,
formattedFileSize = "3MB", formattedFileSize = "3MB",
fileExtension = "ogg", fileExtension = "ogg",
@@ -168,6 +180,7 @@ fun aVoiceMediaInfo(
fun aTxtMediaInfo( fun aTxtMediaInfo(
filename: String = "a text file.txt", filename: String = "a text file.txt",
caption: String? = null, caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null, senderName: String? = null,
dateSent: String? = null, dateSent: String? = null,
dateSentFull: String? = null, dateSentFull: String? = null,
@@ -175,6 +188,7 @@ fun aTxtMediaInfo(
filename = filename, filename = filename,
fileSize = 2 * 1024, fileSize = 2 * 1024,
caption = caption, caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.PlainText, mimeType = MimeTypes.PlainText,
formattedFileSize = "2kB", formattedFileSize = "2kB",
fileExtension = "txt", fileExtension = "txt",
@@ -25,6 +25,9 @@ android {
setupDependencyInjection() setupDependencyInjection()
dependencies { dependencies {
implementation(libs.matrix.richtexteditor.compose)
implementation(libs.matrix.richtexteditor)
implementation(projects.libraries.textcomposer.impl)
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
@@ -32,6 +32,7 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
filename = filename, filename = filename,
fileSize = null, fileSize = null,
caption = null, caption = null,
formattedCaption = null,
mimeType = mimeType, mimeType = mimeType,
formattedFileSize = "", formattedFileSize = "",
fileExtension = "", fileExtension = "",
@@ -98,6 +98,7 @@ class EventItemFactory(
filename = type.filename, filename = type.filename,
fileSize = type.info?.size, fileSize = type.info?.size,
caption = type.caption, caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(), mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename), fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@@ -118,6 +119,7 @@ class EventItemFactory(
filename = type.filename, filename = type.filename,
fileSize = type.info?.size, fileSize = type.info?.size,
caption = type.caption, caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(), mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename), fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@@ -139,6 +141,7 @@ class EventItemFactory(
filename = type.filename, filename = type.filename,
fileSize = type.info?.size, fileSize = type.info?.size,
caption = type.caption, caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(), mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename), fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@@ -160,6 +163,7 @@ class EventItemFactory(
filename = type.filename, filename = type.filename,
fileSize = type.info?.size, fileSize = type.info?.size,
caption = type.caption, caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(), mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename), fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@@ -181,6 +185,7 @@ class EventItemFactory(
filename = type.filename, filename = type.filename,
fileSize = type.info?.size, fileSize = type.info?.size,
caption = type.caption, caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(), mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename), fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@@ -202,6 +207,7 @@ class EventItemFactory(
filename = type.filename, filename = type.filename,
fileSize = type.info?.size, fileSize = type.info?.size,
caption = type.caption, caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(), mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename), fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@@ -97,6 +97,7 @@ class AndroidLocalMediaFactory(
filename = fileName, filename = fileName,
fileSize = fileSize, fileSize = fileSize,
caption = caption, caption = caption,
formattedCaption = null,
formattedFileSize = calculatedFormattedFileSize, formattedFileSize = calculatedFormattedFileSize,
fileExtension = fileExtension, fileExtension = fileExtension,
senderId = senderId, senderId = senderId,
@@ -195,6 +195,86 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
) )
) )
}, },
anImageMediaInfo(
senderName = "Bob",
dateSent = "22 NOV, 2024",
formattedCaption = "This is a <strong>bold</strong> caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Charlie",
dateSent = "23 NOV, 2024",
formattedCaption = "This is an <em>italic</em> caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Diana",
dateSent = "24 NOV, 2024",
formattedCaption = "This is a <code>code</code> caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Eve",
dateSent = "25 NOV, 2024",
formattedCaption = "<blockquote>This is a quote caption</blockquote>",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo(
senderName = "Frank",
dateSent = "26 NOV, 2024",
formattedCaption = "This caption has <strong>bold</strong>, <em>italic</em>, and <code>code</code> formatting.",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
) )
} }
@@ -31,8 +31,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -59,10 +62,12 @@ import androidx.compose.ui.tooling.preview.Devices
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 androidx.compose.ui.unit.dp
import androidx.core.text.toSpannable
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.androidutils.text.safeLinkify
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
@@ -91,7 +96,9 @@ import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
import io.element.android.libraries.mediaviewer.impl.local.PlayableState import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.OverzoomEffect import me.saket.telephoto.zoomable.OverzoomEffect
import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomSpec
@@ -242,6 +249,7 @@ fun MediaViewerView(
MediaViewerBottomBar( MediaViewerBottomBar(
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(), showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
caption = dataForPage.mediaInfo.caption, caption = dataForPage.mediaInfo.caption,
formattedCaption = dataForPage.mediaInfo.formattedCaption,
onHeightChange = { bottomPaddingInPixels = it }, onHeightChange = { bottomPaddingInPixels = it },
) )
} }
@@ -545,6 +553,7 @@ private fun MediaViewerTopBar(
@Composable @Composable
private fun MediaViewerBottomBar( private fun MediaViewerBottomBar(
caption: String?, caption: String?,
formattedCaption: CharSequence?,
showDivider: Boolean, showDivider: Boolean,
onHeightChange: (Int) -> Unit, onHeightChange: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -557,7 +566,7 @@ private fun MediaViewerBottomBar(
onHeightChange(it.height) onHeightChange(it.height)
}, },
) { ) {
if (caption != null) { if (caption != null || formattedCaption != null) {
if (showDivider) { if (showDivider) {
HorizontalDivider() HorizontalDivider()
} }
@@ -568,15 +577,28 @@ private fun MediaViewerBottomBar(
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait), .heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait),
) { ) {
Text( val textToRender = when {
modifier = Modifier formattedCaption != null -> formattedCaption
.fillMaxWidth() caption != null -> caption.safeLinkify().toSpannable()
.padding(16.dp) else -> null
.verticalScroll(scrollState) }
.navigationBarsPadding(), if (textToRender != null) {
text = caption, CompositionLocalProvider(
style = ElementTheme.typography.fontBodyLgRegular, LocalContentColor provides ElementTheme.colors.textPrimary,
) LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
EditorStyledText(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(scrollState)
.navigationBarsPadding(),
text = textToRender,
style = ElementRichTextEditorStyle.textStyle(),
releaseOnDetach = false,
)
}
}
if (showBottomShadow) { if (showBottomShadow) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -235,6 +235,7 @@ class TimelineMediaGalleryDataSourceTest {
filename = "body.jpg", filename = "body.jpg",
fileSize = 888L, fileSize = 888L,
caption = "body.jpg caption", caption = "body.jpg caption",
formattedCaption = "formatted",
mimeType = MimeTypes.Jpeg, mimeType = MimeTypes.Jpeg,
formattedFileSize = "888 Bytes", formattedFileSize = "888 Bytes",
fileExtension = "jpg", fileExtension = "jpg",
@@ -41,6 +41,7 @@ class FakeLocalMediaFactory(
filename = safeName, filename = safeName,
fileSize = null, fileSize = null,
caption = null, caption = null,
formattedCaption = null,
mimeType = mimeType ?: fallbackMimeType, mimeType = mimeType ?: fallbackMimeType,
formattedFileSize = formattedFileSize ?: fallbackFileSize, formattedFileSize = formattedFileSize ?: fallbackFileSize,
fileExtension = fileExtensionExtractor.extractFromName(safeName), fileExtension = fileExtensionExtractor.extractFromName(safeName),
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:178de2176a3932665897486ecbee622083af2d5939f4f1f7f0bdc4667a61e36a
size 668061
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7ac632f2062aa4a13ee46fd2fef6d9e6be25270f4790247131231163670cbe6
size 670668
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f6870b9c1a5aad4257aa4bd7b13d0be5bea281778061d71c711495aadfaafdb8 oid sha256:f72964447f2fa66e8df38fcff4a84ada1cf62e1646a2e8a00c61f8f151abfa8c
size 206057 size 206507
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b544362587868c37edc424b675c6758e7ffcff359fa12c1cfcb874a0502e25de
size 397284
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b6623e0e784a93d6a3aa220b620efe7d1f9e040aedac7f34d01b24262fe101b
size 401770
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c0249f33aca3e50713c87a3826e71985991f0996998132c42a374c6169800023 oid sha256:e3a148746b4f7428688a634f2b1299f0393bf669a665c94fa9af12d7839e72a0
size 130728 size 130834