From 4290a9c3b3b2b6251229c8f89bb997a055bf849e Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:03:14 +0300 Subject: [PATCH] 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 --- .../messages/impl/MessagesPresenter.kt | 10 +++++-- .../TimelineItemContentMessageFactory.kt | 8 ++++++ .../event/TimelineItemAttachmentsContent.kt | 1 + .../model/event/TimelineItemAudioContent.kt | 1 + .../model/event/TimelineItemEventContent.kt | 10 +++++++ .../model/event/TimelineItemFileContent.kt | 1 + .../model/event/TimelineItemGalleryContent.kt | 1 + .../model/event/TimelineItemImageContent.kt | 1 + .../TimelineItemImageContentPreviewParam.kt | 2 ++ .../model/event/TimelineItemStickerContent.kt | 1 + .../model/event/TimelineItemVideoContent.kt | 1 + .../model/event/TimelineItemVoiceContent.kt | 1 + .../messages/impl/MessagesPresenterTest.kt | 27 +++++++++++++++++++ .../TimelineItemContentMessageFactoryTest.kt | 2 ++ 14 files changed, 65 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 3ba8099ecb..6a019d865b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 040d0450ff..75966db1a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -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(), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt index 66d28598a1..dd3578ea61 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAttachmentsContent.kt @@ -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, ) : TimelineItemEventContent, TimelineItemEventMutableContent { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt index f28de4f673..8c191ef140 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 01146a070b..efa73b91fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt index 680797e36b..36beeeb91d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt @@ -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?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index f376a2a00a..840f19488d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -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, ) : TimelineItemEventContent, TimelineItemEventMutableContent { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 4828f22ba8..5e78adc945 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -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?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt index cd93c7c006..316b2ab79b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentPreviewParam.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt index 9e2f08d5c7..65585bc028 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt @@ -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?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt index 80635c6e18..a1c8e279cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt index 29a432e6bd..806a703ea5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt @@ -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, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 227088f78c..4c0abc0a34 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -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 = "Hello world", + ) + ) + val composerRecorder = EventsRecorder() + 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 = "Hello world", + ) + ) + ) + } + } + @Test fun `present - handle action add caption`() = runTest { val composerRecorder = EventsRecorder() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index fc01184764..1c9de095d3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -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"),