Reuse already parsed document instead of parsing it again

This commit is contained in:
Jorge Martín 2025-12-17 18:38:02 +01:00
parent 690182ab90
commit 2ca981ddfa
5 changed files with 59 additions and 44 deletions

View file

@ -9,7 +9,6 @@
package io.element.android.features.messages.impl.timeline.factories.event package io.element.android.features.messages.impl.timeline.factories.event
import android.text.style.URLSpan import android.text.style.URLSpan
import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans import androidx.core.text.getSpans
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
@ -35,11 +34,9 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
@ -50,6 +47,7 @@ import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.jsoup.nodes.Document
import kotlin.time.Duration import kotlin.time.Duration
@Inject @Inject
@ -60,7 +58,7 @@ class TimelineItemContentMessageFactory(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper, private val textPillificationHelper: TextPillificationHelper,
) { ) {
suspend fun create( fun create(
content: MessageContent, content: MessageContent,
senderDisambiguatedDisplayName: String, senderDisambiguatedDisplayName: String,
eventId: EventId?, eventId: EventId?,
@ -68,26 +66,29 @@ class TimelineItemContentMessageFactory(
return when (val messageType = content.type) { return when (val messageType = content.type) {
is EmoteMessageType -> { is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify( val dom = messageType.formatted?.toHtmlDocument(
emoteBody permalinkParser = permalinkParser,
).safeLinkify() prefix = "* $senderDisambiguatedDisplayName",
)
val formattedBody = dom?.let(::parseHtml)
?: textPillificationHelper.pillify(emoteBody).safeLinkify()
TimelineItemEmoteContent( TimelineItemEmoteContent(
body = emoteBody, body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument( htmlDocument = dom,
permalinkParser = permalinkParser,
prefix = "* $senderDisambiguatedDisplayName",
),
formattedBody = formattedBody, formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
} }
is ImageMessageType -> { is ImageMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent( TimelineItemImageContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
@ -103,12 +104,15 @@ class TimelineItemContentMessageFactory(
) )
} }
is StickerMessageType -> { is StickerMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent( TimelineItemStickerContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
@ -140,12 +144,15 @@ class TimelineItemContentMessageFactory(
} }
} }
is VideoMessageType -> { is VideoMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent( TimelineItemVideoContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source, mediaSource = messageType.source,
@ -162,11 +169,14 @@ class TimelineItemContentMessageFactory(
) )
} }
is AudioMessageType -> { is AudioMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
TimelineItemAudioContent( TimelineItemAudioContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
@ -176,12 +186,15 @@ class TimelineItemContentMessageFactory(
) )
} }
is VoiceMessageType -> { is VoiceMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
TimelineItemVoiceContent( TimelineItemVoiceContent(
eventId = eventId, eventId = eventId,
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
mediaSource = messageType.source, mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
@ -192,12 +205,15 @@ class TimelineItemContentMessageFactory(
) )
} }
is FileMessageType -> { is FileMessageType -> {
val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
val formattedCaption = dom?.let(::parseHtml)
?: messageType.caption?.withLinks()
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
TimelineItemFileContent( TimelineItemFileContent(
filename = messageType.filename, filename = messageType.filename,
fileSize = messageType.info?.size ?: 0, fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(), caption = messageType.caption?.trimEnd(),
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), formattedCaption = formattedCaption,
isEdited = content.isEdited, isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource, thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source, mediaSource = messageType.source,
@ -208,9 +224,9 @@ class TimelineItemContentMessageFactory(
} }
is NoticeMessageType -> { is NoticeMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
body val formattedBody = dom?.let(::parseHtml)
).safeLinkify() ?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent( TimelineItemNoticeContent(
body = body, body = body,
@ -221,12 +237,13 @@ class TimelineItemContentMessageFactory(
} }
is TextMessageType -> { is TextMessageType -> {
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
body val formattedBody = dom?.let(::parseHtml)
).safeLinkify() ?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), htmlDocument = htmlDocument,
formattedBody = formattedBody, formattedBody = formattedBody,
isEdited = content.isEdited, isEdited = content.isEdited,
) )
@ -253,21 +270,11 @@ class TimelineItemContentMessageFactory(
return result?.takeIf { it.isFinite() } return result?.takeIf { it.isFinite() }
} }
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? { private fun parseHtml(document: Document): CharSequence? {
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null return htmlConverterProvider.provide()
val result = htmlConverterProvider.provide() .fromDocumentToSpans(document)
.fromHtmlToSpans(formattedBody.body.trimEnd())
.let { textPillificationHelper.pillify(it) } .let { textPillificationHelper.pillify(it) }
.safeLinkify() .safeLinkify()
return if (prefix != null) {
buildSpannedString {
append(prefix)
append(" ")
append(result)
}
} else {
result
}
} }
} }

View file

@ -67,6 +67,7 @@ import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.jsoup.nodes.Document
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -187,7 +188,7 @@ class TimelineItemContentMessageFactoryTest {
} }
}.toSpannable() }.toSpannable()
val sut = createTimelineItemContentMessageFactory( val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expected } domConverterTransform = { expected }
) )
val result = sut.create( val result = sut.create(
content = createMessageContent( content = createMessageContent(
@ -679,7 +680,7 @@ class TimelineItemContentMessageFactoryTest {
} }
}.toSpannable() }.toSpannable()
val sut = createTimelineItemContentMessageFactory( val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expectedSpanned }, domConverterTransform = { expectedSpanned },
permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
) )
val result = sut.create( val result = sut.create(
@ -765,11 +766,12 @@ class TimelineItemContentMessageFactoryTest {
private fun createTimelineItemContentMessageFactory( private fun createTimelineItemContentMessageFactory(
htmlConverterTransform: (String) -> CharSequence = { it }, htmlConverterTransform: (String) -> CharSequence = { it },
domConverterTransform: (Document) -> CharSequence = { it.body().html() },
permalinkParser: FakePermalinkParser = FakePermalinkParser(), permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = TimelineItemContentMessageFactory( ) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(), fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
textPillificationHelper = FakeTextPillificationHelper(), textPillificationHelper = FakeTextPillificationHelper(),
) )

View file

@ -11,9 +11,11 @@ package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.wysiwyg.utils.HtmlConverter import io.element.android.wysiwyg.utils.HtmlConverter
import org.jsoup.nodes.Document
class FakeHtmlConverterProvider( class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it }, private val transform: (String) -> CharSequence = { it },
private val transformDom: (Document) -> CharSequence = { it.html() },
) : HtmlConverterProvider { ) : HtmlConverterProvider {
@Composable @Composable
override fun Update() = Unit override fun Update() = Unit
@ -23,6 +25,10 @@ class FakeHtmlConverterProvider(
override fun fromHtmlToSpans(html: String): CharSequence { override fun fromHtmlToSpans(html: String): CharSequence {
return transform(html) return transform(html)
} }
override fun fromDocumentToSpans(dom: Document): CharSequence {
return transformDom(dom)
}
} }
} }
} }

View file

@ -36,7 +36,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.jsoup) implementation(libs.matrix.richtexteditor)
implementation(projects.libraries.previewutils) implementation(projects.libraries.previewutils)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)

View file

@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import org.jsoup.Jsoup import io.element.android.wysiwyg.utils.HtmlToDomParser
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
/** /**
@ -34,9 +34,9 @@ fun FormattedBody.toHtmlDocument(
?.trimEnd() ?.trimEnd()
?.let { formattedBody -> ?.let { formattedBody ->
val dom = if (prefix != null) { val dom = if (prefix != null) {
Jsoup.parse("$prefix $formattedBody") HtmlToDomParser.document("$prefix $formattedBody")
} else { } else {
Jsoup.parse(formattedBody) HtmlToDomParser.document(formattedBody)
} }
// Prepend `@` to mentions // Prepend `@` to mentions