Add headers (h1-h6) tag support for the new timeline renderer (#7754)

* Add headers (`h1-h6`) tag support for the new timeline renderer

* Make H1 and H2 use the same (smaller) style

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-09-23 14:35:05 +02:00
committed by GitHub
parent fb84c5b3c0
commit b6cf293e2a
32 changed files with 203 additions and 66 deletions
@@ -18,6 +18,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.htmlrenderer.api.BlockNode
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HeaderNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
@@ -30,6 +31,7 @@ import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.jsoup.nodes.Document
@@ -81,12 +83,13 @@ class DefaultHtmlMessageParser(
return blocks.toImmutableList()
}
private fun parseBlock(element: Element): BlockNode? = when (element.tagName()) {
private fun parseBlock(element: Element): BlockNode? = when (val tag = element.tagName()) {
TAG_P -> buildParagraph(element.childNodes())
TAG_PRE -> CodeBlockNode(code = element.wholeText().trimEnd('\n'))
TAG_BLOCKQUOTE -> QuoteNode(children = parseBlocks(element))
TAG_UL -> buildList(element, ordered = false)
TAG_OL -> buildList(element, ordered = true)
in HEADER_TAGS -> buildHeader(element, level = tag.removePrefix("h").toInt())
else -> null
}
@@ -102,6 +105,17 @@ class DefaultHtmlMessageParser(
/** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */
private fun buildParagraph(nodes: List<Node>): ParagraphNode? {
return buildInlineContent(nodes)?.let { ParagraphNode(text = it.text, inlineContent = it.inlineContent) }
}
/** Builds a [HeaderNode] of the given [level] from the inline content of [element], or `null` if empty. */
private fun buildHeader(element: Element, level: Int): HeaderNode? {
return buildInlineContent(element.childNodes())?.let {
HeaderNode(level = level, text = it.text, inlineContent = it.inlineContent)
}
}
private fun buildInlineContent(nodes: List<Node>): InlineContentBuilder.Result? {
val builder = InlineContentBuilder()
nodes.forEach { builder.append(it) }
return builder.build()
@@ -199,21 +213,29 @@ class DefaultHtmlMessageParser(
}
}
fun build(): ParagraphNode? {
fun build(): Result? {
val text = builder.toAnnotatedString()
if (text.isBlank() && mentions.isEmpty()) return null
return ParagraphNode(
return Result(
text = text,
inlineContent = mentions.toImmutableMap(),
)
}
/** The flattened inline content of a block: styled text plus its inline-content placeholders. */
inner class Result(
val text: AnnotatedString,
val inlineContent: ImmutableMap<String, MentionNodeContent>,
)
}
}
/** Tags that start a new block-level node. Anything else is treated as inline content. */
private fun Element.isBlockLevel(): Boolean = tagName() in BLOCK_TAGS
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL)
private val HEADER_TAGS = setOf("h1", "h2", "h3", "h4", "h5", "h6")
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL) + HEADER_TAGS
private const val TAG_P = "p"
private const val TAG_PRE = "pre"
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.HeaderNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
@@ -191,6 +192,24 @@ class DefaultHtmlMessageParserTest : RobolectricTest() {
assertThat(paragraph.text.text).isEqualTo("Actual message")
}
@Test
fun `headers become HeaderNodes with their level and inline content`() {
val document = parse("<h1>Title</h1><h3>Subtitle with <strong>bold</strong></h3><h6>Small</h6>")
assertThat(document.children).hasSize(3)
val h1 = document.children[0] as HeaderNode
assertThat(h1.level).isEqualTo(1)
assertThat(h1.text.text).isEqualTo("Title")
val h3 = document.children[1] as HeaderNode
assertThat(h3.level).isEqualTo(3)
assertThat(h3.text.text).isEqualTo("Subtitle with bold")
val h6 = document.children[2] as HeaderNode
assertThat(h6.level).isEqualTo(6)
assertThat(h6.text.text).isEqualTo("Small")
}
@Test
fun `blank input produces an empty document`() {
val document = parse(" ")