mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
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:
+8
-2
@@ -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)
|
||||||
|
|||||||
+8
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -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 {
|
||||||
|
|||||||
+1
@@ -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,
|
||||||
|
|||||||
+10
@@ -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
|
||||||
|
|||||||
+1
@@ -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?,
|
||||||
|
|||||||
+1
@@ -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 {
|
||||||
|
|||||||
+1
@@ -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?,
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+1
@@ -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?,
|
||||||
|
|||||||
+1
@@ -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,
|
||||||
|
|||||||
+1
@@ -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,
|
||||||
|
|||||||
+27
@@ -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>()
|
||||||
|
|||||||
+2
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user