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.TimelineItemTextBasedContent
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.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
@@ -377,7 +378,7 @@ class MessagesPresenter(
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ReplyInThread -> {
@@ -546,10 +547,15 @@ class MessagesPresenter(
private suspend fun handleActionEditCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
enableTextFormatting: Boolean,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = targetEvent.content.captionOrNull().orEmpty(),
content = if (enableTextFormatting) {
targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull()
} else {
targetEvent.content.captionOrNull()
}.orEmpty(),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
items = galleryItems.toImmutableList(),
)
@@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
attachments = attachments.toImmutableList(),
)
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val attachments: ImmutableList<AttachmentItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
val fileSize: Long?
val caption: String?
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 mimeType: String
val formattedFileSize: String
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
else -> null
}
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
is TimelineItemEventContentWithAttachment -> htmlCaption
is TimelineItemGalleryContent -> htmlCaption
is TimelineItemAttachmentsContent -> htmlCaption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val items: ImmutableList<GalleryItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg",
caption: String? = null,
htmlCaption: String? = null,
width: Int? = null,
height: Int? = 300,
) = TimelineItemImageContent(
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
fileSize = 4 * 1024 * 1024L,
caption = caption,
formattedCaption = null,
htmlCaption = htmlCaption,
isEdited = false,
mediaSource = MediaSource(""),
thumbnailSource = null,
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
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
fun `present - handle action add caption`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvent>()
@@ -297,6 +297,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 555L,
caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
@@ -539,6 +540,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 888L,
caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),