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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 182 additions and 22 deletions

View file

@ -646,6 +646,7 @@ class MessagesFlowNode(
filename = content.filename,
fileSize = content.fileSize,
caption = content.caption,
formattedCaption = content.formattedCaption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,

View file

@ -17,6 +17,7 @@ import kotlinx.parcelize.Parcelize
data class MediaInfo(
val filename: String,
val caption: String?,
val formattedCaption: CharSequence? = null,
val mimeType: String,
val fileSize: Long?,
val formattedFileSize: String,
@ -33,6 +34,7 @@ data class MediaInfo(
fun anImageMediaInfo(
senderId: UserId? = UserId("@alice:server.org"),
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -40,6 +42,7 @@ fun anImageMediaInfo(
filename = "an image file.jpg",
fileSize = 4 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
@ -54,6 +57,7 @@ fun anImageMediaInfo(
fun aVideoMediaInfo(
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -62,6 +66,7 @@ fun aVideoMediaInfo(
filename = "a video file.mp4",
fileSize = 14 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB",
fileExtension = "mp4",
@ -77,6 +82,7 @@ fun aVideoMediaInfo(
fun aPdfMediaInfo(
filename: String = "a pdf file.pdf",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -84,6 +90,7 @@ fun aPdfMediaInfo(
filename = filename,
fileSize = 23 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Pdf,
formattedFileSize = "23MB",
fileExtension = "pdf",
@ -105,6 +112,7 @@ fun anApkMediaInfo(
filename = "an apk file.apk",
fileSize = 50 * 1024 * 1024,
caption = null,
formattedCaption = null,
mimeType = MimeTypes.Apk,
formattedFileSize = "50MB",
fileExtension = "apk",
@ -120,6 +128,7 @@ fun anApkMediaInfo(
fun anAudioMediaInfo(
filename: String = "an audio file.mp3",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -129,6 +138,7 @@ fun anAudioMediaInfo(
filename = filename,
fileSize = 7 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
@ -144,6 +154,7 @@ fun anAudioMediaInfo(
fun aVoiceMediaInfo(
filename: String = "a voice file.ogg",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -153,6 +164,7 @@ fun aVoiceMediaInfo(
filename = filename,
fileSize = 3 * 1024 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.Ogg,
formattedFileSize = "3MB",
fileExtension = "ogg",
@ -168,6 +180,7 @@ fun aVoiceMediaInfo(
fun aTxtMediaInfo(
filename: String = "a text file.txt",
caption: String? = null,
formattedCaption: CharSequence? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
@ -175,6 +188,7 @@ fun aTxtMediaInfo(
filename = filename,
fileSize = 2 * 1024,
caption = caption,
formattedCaption = formattedCaption,
mimeType = MimeTypes.PlainText,
formattedFileSize = "2kB",
fileExtension = "txt",

View file

@ -25,6 +25,9 @@ android {
setupDependencyInjection()
dependencies {
implementation(libs.matrix.richtexteditor.compose)
implementation(libs.matrix.richtexteditor)
implementation(projects.libraries.textcomposer.impl)
implementation(libs.coroutines.core)
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)

View file

@ -32,6 +32,7 @@ class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
filename = filename,
fileSize = null,
caption = null,
formattedCaption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",

View file

@ -98,6 +98,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -118,6 +119,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -139,6 +141,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -160,6 +163,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -181,6 +185,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
@ -202,6 +207,7 @@ class EventItemFactory(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
formattedCaption = type.formattedCaption?.body,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),

View file

@ -97,6 +97,7 @@ class AndroidLocalMediaFactory(
filename = fileName,
fileSize = fileSize,
caption = caption,
formattedCaption = null,
formattedFileSize = calculatedFormattedFileSize,
fileExtension = fileExtension,
senderId = senderId,

View file

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

View file

@ -31,8 +31,11 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.core.text.toSpannable
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.audio.api.AudioFocus
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.rememberLocalMediaViewState
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.wysiwyg.compose.EditorStyledText
import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.OverzoomEffect
import me.saket.telephoto.zoomable.ZoomSpec
@ -242,6 +249,7 @@ fun MediaViewerView(
MediaViewerBottomBar(
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
caption = dataForPage.mediaInfo.caption,
formattedCaption = dataForPage.mediaInfo.formattedCaption,
onHeightChange = { bottomPaddingInPixels = it },
)
}
@ -545,6 +553,7 @@ private fun MediaViewerTopBar(
@Composable
private fun MediaViewerBottomBar(
caption: String?,
formattedCaption: CharSequence?,
showDivider: Boolean,
onHeightChange: (Int) -> Unit,
modifier: Modifier = Modifier,
@ -557,7 +566,7 @@ private fun MediaViewerBottomBar(
onHeightChange(it.height)
},
) {
if (caption != null) {
if (caption != null || formattedCaption != null) {
if (showDivider) {
HorizontalDivider()
}
@ -568,15 +577,28 @@ private fun MediaViewerBottomBar(
.fillMaxWidth()
.heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(scrollState)
.navigationBarsPadding(),
text = caption,
style = ElementTheme.typography.fontBodyLgRegular,
)
val textToRender = when {
formattedCaption != null -> formattedCaption
caption != null -> caption.safeLinkify().toSpannable()
else -> null
}
if (textToRender != null) {
CompositionLocalProvider(
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) {
Box(
modifier = Modifier

View file

@ -235,6 +235,7 @@ class TimelineMediaGalleryDataSourceTest {
filename = "body.jpg",
fileSize = 888L,
caption = "body.jpg caption",
formattedCaption = "formatted",
mimeType = MimeTypes.Jpeg,
formattedFileSize = "888 Bytes",
fileExtension = "jpg",

View file

@ -41,6 +41,7 @@ class FakeLocalMediaFactory(
filename = safeName,
fileSize = null,
caption = null,
formattedCaption = null,
mimeType = mimeType ?: fallbackMimeType,
formattedFileSize = formattedFileSize ?: fallbackFileSize,
fileExtension = fileExtensionExtractor.extractFromName(safeName),

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5aa3bca6cd248ac4725fb35aa11a465029dc534b8b167aedbf3e9bc240577e9c
size 654171
oid sha256:4e8ce597c240a7e72b6811537b6fd24e1bd38714db0ce074377ab3f16eaf0436
size 656958

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd5b76aefb0fde0605556e78c7286bf8ee8dd465def0e3095ebc97ce3427eafa
size 666239
oid sha256:21b8289db279172f042fa902bdbdb70d87104d342a1140357bb1ac2bc18f6590
size 668482

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f6870b9c1a5aad4257aa4bd7b13d0be5bea281778061d71c711495aadfaafdb8
size 206057
oid sha256:f72964447f2fa66e8df38fcff4a84ada1cf62e1646a2e8a00c61f8f151abfa8c
size 206507

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bddbe3b0e66e0a2ce49b39321057c94427933de81663f680237a398e9929ba3
size 442729
oid sha256:2adfc04e0f7999ba861bf931b853f068067387558e81464e99caba1c2445a3a7
size 444811

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3bd4a96daaa24c01b7d0007fdd14460dc80c65c13d0ccc7a887b04fd90e9fa99
size 396805
oid sha256:d56f08f1c37575de90a67ebfeadaf1c891c9ac9e4925af7d048922df9fec5aa9
size 396815

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c0249f33aca3e50713c87a3826e71985991f0996998132c42a374c6169800023
size 130728
oid sha256:e3a148746b4f7428688a634f2b1299f0393bf669a665c94fa9af12d7839e72a0
size 130834