Keep the formatting of a caption that is being edited

Editing the caption of a media message dropped all of its formatting: bold,
links and the rest came back as plain text, and saving wrote that plain text
over the formatted caption.

The composer was being opened on the plain caption. Editing a message body
already starts from its HTML when rich text editing is on, and the caption
path was the only one that did not - it read `captionOrNull()` and never had
the caption's HTML to read, because the timeline content only kept the
already-parsed `formattedCaption`.

Media contents now carry the caption's HTML alongside the parsed one, the
same way text contents carry `htmlBody`, and editing a caption starts from it
when rich text editing is on. The save path already sent the composer's HTML,
so nothing changes there.

Part of #6730
This commit is contained in:
hayaksi1
2026-08-25 22:03:14 +03:00
parent a2d3488426
commit 4290a9c3b3
14 changed files with 65 additions and 2 deletions
@@ -51,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.RoomCallState
@@ -377,7 +378,7 @@ class MessagesPresenter(
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState) TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState) TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent) TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState) TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ReplyInThread -> { TimelineItemAction.ReplyInThread -> {
@@ -546,10 +547,15 @@ class MessagesPresenter(
private suspend fun handleActionEditCaption( private suspend fun handleActionEditCaption(
targetEvent: TimelineItem.Event, targetEvent: TimelineItem.Event,
composerState: MessageComposerState, composerState: MessageComposerState,
enableTextFormatting: Boolean,
) { ) {
val composerMode = MessageComposerMode.EditCaption( val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId, eventOrTransactionId = targetEvent.eventOrTransactionId,
content = targetEvent.content.captionOrNull().orEmpty(), content = if (enableTextFormatting) {
targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull()
} else {
targetEvent.content.captionOrNull()
}.orEmpty(),
) )
composerState.eventSink( composerState.eventSink(
MessageComposerEvent.SetMode(composerMode) MessageComposerEvent.SetMode(composerMode)
@@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
@@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
@@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source, mediaSource = messageType.source,
@@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
@@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
@@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source, mediaSource = messageType.source,
@@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body, body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
items = galleryItems.toImmutableList(), items = galleryItems.toImmutableList(),
) )
@@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body, body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() }, caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption, formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited, isEdited = content.isEdited,
attachments = attachments.toImmutableList(), attachments = attachments.toImmutableList(),
) )
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
val body: String, val body: String,
val caption: String?, val caption: String?,
val formattedCaption: CharSequence?, val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
val attachments: ImmutableList<AttachmentItem>, val attachments: ImmutableList<AttachmentItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent { ) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
override val fileSize: Long?, override val fileSize: Long?,
override val caption: String?, override val caption: String?,
override val formattedCaption: CharSequence?, override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
val duration: Duration, val duration: Duration,
override val mediaSource: MediaSource, override val mediaSource: MediaSource,
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
val fileSize: Long? val fileSize: Long?
val caption: String? val caption: String?
val formattedCaption: CharSequence? val formattedCaption: CharSequence?
/** The caption as it was written, in HTML, so that editing it can start from the formatted text. */
val htmlCaption: String?
val mediaSource: MediaSource val mediaSource: MediaSource
val mimeType: String val mimeType: String
val formattedFileSize: String val formattedFileSize: String
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
else -> null else -> null
} }
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
is TimelineItemEventContentWithAttachment -> htmlCaption
is TimelineItemGalleryContent -> htmlCaption
is TimelineItemAttachmentsContent -> htmlCaption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? { fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) { return when (this) {
is TimelineItemAudioContent -> duration is TimelineItemAudioContent -> duration
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
override val fileSize: Long?, override val fileSize: Long?,
override val caption: String?, override val caption: String?,
override val formattedCaption: CharSequence?, override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
override val mediaSource: MediaSource, override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
val body: String, val body: String,
val caption: String?, val caption: String?,
val formattedCaption: CharSequence?, val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
val items: ImmutableList<GalleryItem>, val items: ImmutableList<GalleryItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent { ) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
override val fileSize: Long?, override val fileSize: Long?,
override val caption: String?, override val caption: String?,
override val formattedCaption: CharSequence?, override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
override val mediaSource: MediaSource, override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
blurhash: String? = A_BLUR_HASH, blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg", filename: String = "A picture.jpg",
caption: String? = null, caption: String? = null,
htmlCaption: String? = null,
width: Int? = null, width: Int? = null,
height: Int? = 300, height: Int? = 300,
) = TimelineItemImageContent( ) = TimelineItemImageContent(
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
fileSize = 4 * 1024 * 1024L, fileSize = 4 * 1024 * 1024L,
caption = caption, caption = caption,
formattedCaption = null, formattedCaption = null,
htmlCaption = htmlCaption,
isEdited = false, isEdited = false,
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
override val fileSize: Long?, override val fileSize: Long?,
override val caption: String?, override val caption: String?,
override val formattedCaption: CharSequence?, override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
override val mediaSource: MediaSource, override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
override val fileSize: Long?, override val fileSize: Long?,
override val caption: String?, override val caption: String?,
override val formattedCaption: CharSequence?, override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
val duration: Duration, val duration: Duration,
override val mediaSource: MediaSource, override val mediaSource: MediaSource,
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
override val fileSize: Long?, override val fileSize: Long?,
override val caption: String?, override val caption: String?,
override val formattedCaption: CharSequence?, override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean, override val isEdited: Boolean,
val duration: Duration, val duration: Duration,
override val mediaSource: MediaSource, override val mediaSource: MediaSource,
@@ -989,6 +989,33 @@ class MessagesPresenterTest {
} }
} }
@Test
fun `present - handle action edit caption starts from the formatted caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = "Hello world",
htmlCaption = "<b>Hello</b> world",
)
)
val composerRecorder = EventsRecorder<MessageComposerEvent>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder, showTextFormatting = true) },
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvent.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = "<b>Hello</b> world",
)
)
)
}
}
@Test @Test
fun `present - handle action add caption`() = runTest { fun `present - handle action add caption`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvent>() val composerRecorder = EventsRecorder<MessageComposerEvent>()
@@ -297,6 +297,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 555L, fileSize = 555L,
caption = "body.mp4 caption", caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"), formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true, isEdited = true,
duration = 1.minutes, duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null), mediaSource = MediaSource(url = "url", json = null),
@@ -539,6 +540,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 888L, fileSize = 888L,
caption = "body.jpg caption", caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"), formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true, isEdited = true,
mediaSource = MediaSource(url = "url", json = null), mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"), thumbnailSource = MediaSource("url_thumbnail"),