New timeline event renderer: add new modules (#7721)

* Added HTML parser to abstract tree nodes

* Add HTML renderer using `HtmlMessageContent`

* Add custom rendering for inline code nodes besides the monospace font

* Support `<s>` and `<strike>` too

* Add `ContentAvoidingLayoutData` support

* Improve the last block node behaviour and its integration with `ContentAvoidingLayout`

* Move `ContentAvoidingLayout` to `:libraries:ui-common` so it can be shared

* Add missing `:libraries:htmlrenderer:impl` entry in `DependencyHandlerScope.allLibrariesImpl`

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-09-17 19:08:32 +02:00
committed by GitHub
parent 97d9707d6a
commit c0bb68c872
123 changed files with 1592 additions and 169 deletions
@@ -0,0 +1,36 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.htmlrenderer.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.libraries.htmlrenderer.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.uiCommon)
implementation(libs.jsoup)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,237 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import dev.zacsweers.metro.AppScope
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.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
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
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.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
/**
* Recursive-descent [HtmlMessageParser] over a (sanitized) jsoup [Document].
*
* Block-level elements become their matching [BlockNode]; consecutive inline nodes are
* coalesced into a single [ParagraphNode] whose text is an [AnnotatedString].
*/
@ContributesBinding(AppScope::class)
class DefaultHtmlMessageParser(
private val permalinkParser: PermalinkParser,
) : HtmlMessageParser {
override fun parse(document: Document): DocumentNode {
return DocumentNode(children = parseBlocks(document.body()))
}
/**
* Walks the children of [parent], emitting a block node for each block-level element and
* buffering runs of inline content into [ParagraphNode]s in between.
*/
private fun parseBlocks(parent: Element): ImmutableList<BlockNode> {
val blocks = mutableListOf<BlockNode>()
val inlineBuffer = mutableListOf<Node>()
fun flushInline() {
if (inlineBuffer.isNotEmpty()) {
buildParagraph(inlineBuffer)?.let(blocks::add)
inlineBuffer.clear()
}
}
for (node in parent.childNodes()) {
when (node) {
// The fallback reply content is never rendered.
is Element if node.tagName() == TAG_MX_REPLY -> Unit
is Element if node.isBlockLevel() -> {
flushInline()
parseBlock(node)?.let(blocks::add)
}
else -> inlineBuffer.add(node)
}
}
flushInline()
return blocks.toImmutableList()
}
private fun parseBlock(element: Element): BlockNode? = when (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)
else -> null
}
private fun buildList(element: Element, ordered: Boolean): ListNode {
val startIndex = if (ordered) element.attr("start").toIntOrNull() ?: 1 else 1
val items = element.childNodes()
.filterIsInstance<Element>()
.filter { it.tagName() == TAG_LI }
.map { ListItemNode(children = parseBlocks(it)) }
.toImmutableList()
return ListNode(ordered = ordered, startIndex = startIndex, items = items)
}
/** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */
private fun buildParagraph(nodes: List<Node>): ParagraphNode? {
val builder = InlineContentBuilder()
nodes.forEach { builder.append(it) }
return builder.build()
}
/**
* Accumulates inline nodes into an [AnnotatedString] plus a map of mention placeholders.
* A fresh instance is used for each paragraph so placeholder ids stay local to it.
*/
private inner class InlineContentBuilder {
private val builder = AnnotatedString.Builder()
private val mentions = mutableMapOf<String, MentionNodeContent>()
private var mentionCount = 0
fun append(node: Node) {
when (node) {
is TextNode -> builder.append(node.text())
is Element -> appendElement(node)
else -> Unit
}
}
private fun appendElement(element: Element) {
when (element.tagName()) {
TAG_BR -> builder.append("\n")
TAG_B, TAG_STRONG -> withStyle(SpanStyle(fontWeight = FontWeight.Bold), element)
TAG_I, TAG_EM -> withStyle(SpanStyle(fontStyle = FontStyle.Italic), element)
TAG_U -> withStyle(SpanStyle(textDecoration = TextDecoration.Underline), element)
TAG_DEL, TAG_STRIKE_SHORT, TAG_STRIKE -> withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), element)
TAG_CODE -> appendInlineCode(element)
TAG_A -> appendLink(element)
TAG_MX_REPLY -> Unit
// Unknown inline wrapper: keep its text so nothing is dropped.
else -> appendChildren(element)
}
}
private fun appendChildren(element: Element) {
element.childNodes().forEach { append(it) }
}
private fun withStyle(style: SpanStyle, element: Element) {
val start = builder.length
appendChildren(element)
builder.addStyle(style, start, builder.length)
}
/**
* Renders an inline `<code>` as monospaced text and annotates the range so the renderer can
* apply a code background. A `<code>` inside a `<pre>` never reaches here: the `<pre>` is
* turned into a [CodeBlockNode] from its raw text.
*/
private fun appendInlineCode(element: Element) {
val start = builder.length
appendChildren(element)
if (builder.length > start) {
builder.addStyle(SpanStyle(fontFamily = FontFamily.Monospace), start, builder.length)
builder.addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, builder.length)
}
}
private fun appendLink(element: Element) {
val href = element.attr("href")
val mention = href.takeIf { it.isNotEmpty() }?.let { resolveMention(element, it) }
if (mention != null) {
val id = "$MENTION_ID_PREFIX${mentionCount++}"
mentions[id] = mention
builder.appendInlineContent(id, mention.displayText)
return
}
// Only the link range is annotated; the visual style (color, ...) is applied by the renderer.
val start = builder.length
appendChildren(element)
if (href.isNotEmpty() && builder.length > start) {
builder.addStringAnnotation(LINK_ANNOTATION_TAG, href, start, builder.length)
}
}
private fun resolveMention(element: Element, href: String): MentionNodeContent? {
return when (val permalink = permalinkParser.parse(href)) {
is PermalinkData.UserLink -> MentionNodeContent.User(
displayText = element.text(),
userId = permalink.userId,
)
is PermalinkData.RoomLink -> if (permalink.eventId == null) {
MentionNodeContent.Room(
displayText = element.text(),
roomIdOrAlias = permalink.roomIdOrAlias,
)
} else {
// A link to a specific event is a regular link, not a mention pill.
null
}
else -> null
}
}
fun build(): ParagraphNode? {
val text = builder.toAnnotatedString()
if (text.isBlank() && mentions.isEmpty()) return null
return ParagraphNode(
text = text,
inlineContent = mentions.toImmutableMap(),
)
}
}
}
/** 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 const val TAG_P = "p"
private const val TAG_PRE = "pre"
private const val TAG_BLOCKQUOTE = "blockquote"
private const val TAG_UL = "ul"
private const val TAG_OL = "ol"
private const val TAG_LI = "li"
private const val TAG_BR = "br"
private const val TAG_B = "b"
private const val TAG_STRONG = "strong"
private const val TAG_I = "i"
private const val TAG_EM = "em"
private const val TAG_U = "u"
private const val TAG_DEL = "del"
private const val TAG_STRIKE_SHORT = "s"
private const val TAG_STRIKE = "strike"
private const val TAG_CODE = "code"
private const val TAG_A = "a"
private const val TAG_MX_REPLY = "mx-reply"
private const val MENTION_ID_PREFIX = "mention_"
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl.renderer
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import kotlinx.collections.immutable.persistentListOf
private val documentNode = DocumentNode(
persistentListOf(
ParagraphNode(AnnotatedString("Hello World, this is a")),
QuoteNode(persistentListOf(ParagraphNode(AnnotatedString("Quote with a very long text that wraps")))),
CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"),
ListNode(
ordered = true,
startIndex = 0,
items = persistentListOf(
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("First item")))),
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Second item")))),
ListItemNode(persistentListOf(ParagraphNode(AnnotatedString("Third item")))),
)
)
)
)
@Composable
private fun FakeTimestampView() {
Text(text = "12:34", modifier = Modifier.background(Color.LightGray).padding(start = 12.dp))
}
@Suppress("UnusedPrivateMember")
@Preview(showBackground = true)
@Composable
private fun ContentAvoidingLayoutHtmlMessageContentPreview() {
Box(modifier = Modifier.padding(WindowInsets.safeContent.asPaddingValues())) {
ContentAvoidingLayout(
overlay = { FakeTimestampView() },
content = {
HtmlMessageContent(
modifier = Modifier.background(Color.Red),
node = documentNode,
onContentLayoutChange = this::onContentLayoutChange,
)
}
)
}
}
@@ -0,0 +1,362 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl.renderer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
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.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
/**
* Renders a parsed message tree ([DocumentNode]) as native Compose content.
*
* The renderer is stateless: it is driven entirely by [node] and reports interactions
* through the callbacks. It performs no HTML parsing itself.
*
* @param node the root HTML node of the parsed message tree to render.
* @param modifier applied to the root of the rendered content.
* @param currentUserId used to style a mention of the current user differently; may be null.
* @param onLinkClick invoked with the target URL when a link is tapped.
* @param onLinkLongClick invoked with the target URL when a link is long-pressed.
* @param onMentionClick invoked when a mention pill is tapped.
* @param onContentLayoutChange invoked with the measured layout of the very last rendered
* [ParagraphNode], so the timeline can lay out the timestamp around it. It is only wired to that
* last paragraph, and only when it is not nested inside a [CodeBlockNode] or [QuoteNode] — in which
* case no measurement is reported.
*/
@Composable
fun HtmlMessageContent(
node: DocumentNode,
modifier: Modifier = Modifier,
currentUserId: UserId? = null,
onLinkClick: (String) -> Unit = {},
onLinkLongClick: (String) -> Unit = {},
onMentionClick: (MentionNodeContent) -> Unit = {},
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) {
val measuredParagraph = remember(node) { node.lastBlockNode() }
val context = remember(currentUserId, onLinkClick, onLinkLongClick, onMentionClick, measuredParagraph, onContentLayoutChange) {
RenderContext(
currentUserId = currentUserId,
onLinkClick = onLinkClick,
onLinkLongClick = onLinkLongClick,
onMentionClick = onMentionClick,
lastBlockNode = measuredParagraph,
onContentLayoutChange = onContentLayoutChange,
)
}
SelectionContainer {
BlockNodes(nodes = node.children, context = context, modifier = modifier)
}
}
/** Interaction callbacks and state threaded through the render tree. */
@Immutable
private data class RenderContext(
val currentUserId: UserId?,
val onLinkClick: (String) -> Unit,
val onLinkLongClick: (String) -> Unit,
val onMentionClick: (MentionNodeContent) -> Unit,
// The latest descendant BlockNode of the root DocumentNode
val lastBlockNode: BlockNode?,
val onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
)
internal fun BlockNode.lastBlockNode(): BlockNode? = when (this) {
is ParagraphNode -> this
is DocumentNode -> children.lastOrNull()?.lastBlockNode()
is ListNode -> this
is QuoteNode -> this
is CodeBlockNode -> this
}
@Composable
private fun BlockNodes(
nodes: ImmutableList<BlockNode>,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(BlockSpacing),
) {
nodes.forEach { BlockNodeView(node = it, context = context) }
}
}
@Composable
private fun BlockNodeView(
node: BlockNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
if (node === context.lastBlockNode && node !is ParagraphNode) {
SideEffect { context.onContentLayoutChange(ContentAvoidingLayoutData.NotOverlapping) }
}
when (node) {
is DocumentNode -> BlockNodes(nodes = node.children, context = context, modifier = modifier)
is ParagraphNode -> {
ParagraphView(node = node, context = context, modifier = modifier)
}
is CodeBlockNode -> CodeBlockView(node = node, modifier = modifier)
is QuoteNode -> QuoteView(node = node, context = context, modifier = modifier)
is ListNode -> ListView(node = node, context = context, modifier = modifier)
}
}
@Composable
private fun ParagraphView(
node: ParagraphNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
val linkColor = ElementTheme.colors.textLinkExternal
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
val styledText = remember(node.text, linkColor, codeBackgroundColor) {
node.text.applyInlineStyles(linkColor = linkColor, codeBackgroundColor = codeBackgroundColor)
}
val inlineContent = rememberMentionInlineContent(node.inlineContent, context)
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
// measured last line to the timeline.
val measureLastLine = if (node === context.lastBlockNode) {
ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange)
} else {
null
}
Text(
text = styledText,
modifier = modifier.linkTapHandler(styledText, layoutResult, context),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
inlineContent = inlineContent,
onTextLayout = { result ->
layoutResult.value = result
measureLastLine?.invoke(result)
},
)
}
@Composable
private fun CodeBlockView(
node: CodeBlockNode,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = node.code,
style = ElementTheme.typography.fontBodyMdRegular.copy(fontFamily = FontFamily.Monospace),
color = ElementTheme.colors.textPrimary,
softWrap = false,
)
}
}
@Composable
private fun QuoteView(
node: QuoteNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.width(2.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(1.dp))
.background(ElementTheme.colors.borderInteractiveSecondary),
)
Spacer(modifier = Modifier.width(8.dp))
BlockNodes(nodes = node.children, context = context)
}
}
@Composable
private fun ListView(
node: ListNode,
context: RenderContext,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(ListItemSpacing),
) {
node.items.forEachIndexed { index, item ->
Row {
Text(
text = if (node.ordered) "${node.startIndex + index}." else "•",
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.widthIn(min = ListMarkerWidth),
)
BlockNodes(nodes = item.children, context = context)
}
}
}
}
@Composable
private fun MentionPill(
mention: MentionNodeContent,
isCurrentUser: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val backgroundColor = if (isCurrentUser) ElementTheme.colors.bgBadgeAccent else ElementTheme.colors.bgBadgePrimary
val textColor = if (isCurrentUser) ElementTheme.colors.textBadgeAccent else ElementTheme.colors.textOnSolidPrimary
Box(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(percent = 50))
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(horizontal = PillPaddingHorizontal, vertical = PillPaddingVertical),
contentAlignment = Alignment.Center,
) {
Text(
text = mention.displayText,
style = ElementTheme.typography.fontBodyLgMedium,
color = textColor,
maxLines = 1,
)
}
}
/**
* Builds an [InlineTextContent] entry for each mention placeholder. Each pill is measured so
* the placeholder reserves exactly the space the pill needs.
*/
@Composable
private fun rememberMentionInlineContent(
mentions: ImmutableMap<String, MentionNodeContent>,
context: RenderContext,
): ImmutableMap<String, InlineTextContent> {
if (mentions.isEmpty()) return persistentMapOf()
val textMeasurer = rememberTextMeasurer()
val pillTextStyle = ElementTheme.typography.fontBodyLgMedium
val density = LocalDensity.current
return mentions.mapValues { (_, mention) ->
val measured = textMeasurer.measure(mention.displayText, pillTextStyle)
val width = with(density) { measured.size.width.toDp() + PillPaddingHorizontal * 2 }
val height = with(density) { measured.size.height.toDp() + PillPaddingVertical * 2 }
InlineTextContent(
placeholder = Placeholder(
width = with(density) { width.toSp() },
height = with(density) { height.toSp() },
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
) {
MentionPill(
mention = mention,
isCurrentUser = mention is MentionNodeContent.User && mention.userId == context.currentUserId,
onClick = { context.onMentionClick(mention) },
)
}
}.toImmutableMap()
}
/** Overlays theme-dependent styling (link color, inline-code background) onto the annotated ranges. */
private fun AnnotatedString.applyInlineStyles(
linkColor: Color,
codeBackgroundColor: Color,
): AnnotatedString {
val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length)
val codeRanges = getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, length)
if (linkRanges.isEmpty() && codeRanges.isEmpty()) return this
return buildAnnotatedString {
append(this@applyInlineStyles)
linkRanges.forEach { addStyle(SpanStyle(color = linkColor), it.start, it.end) }
codeRanges.forEach { addStyle(SpanStyle(background = codeBackgroundColor), it.start, it.end) }
}
}
private fun Modifier.linkTapHandler(
text: AnnotatedString,
layoutResult: State<TextLayoutResult?>,
context: RenderContext,
): Modifier = pointerInput(text) {
detectTapGestures(
onTap = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkClick) },
onLongPress = { offset -> layoutResult.value?.urlAt(offset, text)?.let(context.onLinkLongClick) },
)
}
private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): String? {
val position = getOffsetForPosition(offset)
return text.getStringAnnotations(LINK_ANNOTATION_TAG, position, position).firstOrNull()?.item
}
private val BlockSpacing: Dp = 8.dp
private val ListItemSpacing: Dp = 4.dp
private val ListMarkerWidth: Dp = 24.dp
private val PillPaddingHorizontal: Dp = 6.dp
private val PillPaddingVertical: Dp = 2.dp
@@ -0,0 +1,212 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl.renderer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
import io.element.android.libraries.htmlrenderer.api.ListItemNode
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
@PreviewsDayNight
@Composable
internal fun HtmlMessageContentPreview(
@PreviewParameter(HtmlMessageContentPreviewParam::class) node: DocumentNode,
) = ElementPreview {
HtmlMessageContent(
node = node,
modifier = Modifier.padding(16.dp),
currentUserId = UserId("@me:example.org"),
)
}
internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<DocumentNode> {
override val values: Sequence<DocumentNode>
get() = sequenceOf(
richParagraph(),
inlineCode(),
multilineInlineCode(),
orderedList(),
quote(),
nestedQuotes(),
codeBlock(),
mention(),
nestedList(),
nestedListWithComplexContents(),
)
private fun richParagraph() = document(
paragraph(
buildAnnotatedString {
append("Hello ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("bold") }
append(", ")
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append("italic") }
append(" and a ")
val start = length
append("link")
addStringAnnotation(LINK_ANNOTATION_TAG, "https://element.io", start, length)
}
),
)
private fun inlineCode() = document(
paragraph(
buildAnnotatedString {
append("Run ")
val start = length
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append("git status") }
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(" to see changes")
}
),
)
private fun multilineInlineCode() = document(
QuoteNode(persistentListOf(
paragraph(
buildAnnotatedString {
append("Trying ")
val start = length
withStyle(
SpanStyle(fontFamily = FontFamily.Monospace)
) { append("inline code when it needs to be wrapped in several lines, just to check what it looks like") }
addStringAnnotation(INLINE_CODE_ANNOTATION_TAG, "", start, length)
append(", is it good?")
}
),
))
)
private fun orderedList() = document(
ListNode(
ordered = true,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(paragraph(AnnotatedString("First item")))),
ListItemNode(persistentListOf(paragraph(AnnotatedString("Second item")))),
),
),
)
private fun quote() = document(
paragraph(AnnotatedString("Someone said:")),
QuoteNode(persistentListOf(paragraph(AnnotatedString("This is a quoted message.")))),
)
private fun nestedQuotes() = document(
paragraph(AnnotatedString("Someone said:")),
QuoteNode(persistentListOf(
paragraph(AnnotatedString("This is a quoted message.")),
QuoteNode(persistentListOf(
paragraph(AnnotatedString("This is a nested quote.")),
codeBlock(),
)),
)),
)
private fun codeBlock() = document(
CodeBlockNode("fun main() {\n println(\"Hello, world!\")\n}"),
)
private fun mention() = document(
paragraph(
text = buildAnnotatedString {
append("Hey ")
appendInlineContent(MENTION_ID, "@alice")
append(", welcome!")
},
inlineContent = persistentMapOf(
MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")),
),
),
)
private fun nestedList() = document(
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(
persistentListOf(
paragraph(AnnotatedString("Parent item")),
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(paragraph(AnnotatedString("Nested item")))),
),
),
),
),
),
),
)
private fun nestedListWithComplexContents() = document(
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(
persistentListOf(
paragraph(AnnotatedString("Parent item")),
ListNode(
ordered = false,
startIndex = 1,
items = persistentListOf(
ListItemNode(persistentListOf(
paragraph(AnnotatedString("Nested item")),
codeBlock(),
quote(),
)),
),
),
),
),
),
),
)
private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList())
private fun paragraph(
text: AnnotatedString,
inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
) = ParagraphNode(text = text, inlineContent = inlineContent)
private companion object {
const val MENTION_ID = "mention_0"
}
}
@@ -0,0 +1,204 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.htmlrenderer.impl
import androidx.compose.ui.text.font.FontFamily
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.HtmlMessageParser
import io.element.android.libraries.htmlrenderer.api.ListNode
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
import io.element.android.libraries.htmlrenderer.api.ParagraphNode
import io.element.android.libraries.htmlrenderer.api.QuoteNode
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.jsoup.Jsoup
import org.junit.Test
class DefaultHtmlMessageParserTest : RobolectricTest() {
@Test
fun `plain text becomes a single paragraph`() {
val document = parse("Hello world")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Hello world")
assertThat(paragraph.inlineContent).isEmpty()
}
@Test
fun `inline formatting is encoded as span styles`() {
val document = parse("Hello <strong>bold</strong> and <em>italic</em>")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Hello bold and italic")
val boldStart = paragraph.text.text.indexOf("bold")
val bold = paragraph.text.spanStyles.single { it.item.fontWeight == FontWeight.Bold }
assertThat(bold.start).isEqualTo(boldStart)
assertThat(bold.end).isEqualTo(boldStart + "bold".length)
val italicStart = paragraph.text.text.indexOf("italic")
val italic = paragraph.text.spanStyles.single { it.item.fontStyle == FontStyle.Italic }
assertThat(italic.start).isEqualTo(italicStart)
assertThat(italic.end).isEqualTo(italicStart + "italic".length)
}
@Test
fun `unordered list produces a bulleted ListNode`() {
val document = parse("<ul><li>First</li><li>Second</li></ul>")
val list = document.children.single() as ListNode
assertThat(list.ordered).isFalse()
assertThat(list.startIndex).isEqualTo(1)
assertThat(list.items).hasSize(2)
val firstItem = list.items[0].children.single() as ParagraphNode
assertThat(firstItem.text.text).isEqualTo("First")
}
@Test
fun `ordered list honors the start attribute`() {
val document = parse("""<ol start="5"><li>Fifth</li></ol>""")
val list = document.children.single() as ListNode
assertThat(list.ordered).isTrue()
assertThat(list.startIndex).isEqualTo(5)
}
@Test
fun `a list nested inside a list item is preserved`() {
val document = parse("<ul><li>Parent<ul><li>Child</li></ul></li></ul>")
val outerList = document.children.single() as ListNode
val outerItem = outerList.items.single()
// The item holds a paragraph ("Parent") followed by the nested list.
val paragraph = outerItem.children[0] as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Parent")
val nestedList = outerItem.children[1] as ListNode
val nestedItem = nestedList.items.single().children.single() as ParagraphNode
assertThat(nestedItem.text.text).isEqualTo("Child")
}
@Test
fun `blockquote wraps its block children`() {
val document = parse("<blockquote><p>Quoted</p></blockquote>")
val quote = document.children.single() as QuoteNode
val paragraph = quote.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Quoted")
}
@Test
fun `blockquotes can nest`() {
val document = parse("<blockquote><blockquote><p>Deep</p></blockquote></blockquote>")
val outer = document.children.single() as QuoteNode
val inner = outer.children.single() as QuoteNode
val paragraph = inner.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Deep")
}
@Test
fun `inline code is monospaced and annotated`() {
val document = parse("Run <code>git status</code> now")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Run git status now")
val start = paragraph.text.text.indexOf("git status")
val end = start + "git status".length
val monospace = paragraph.text.spanStyles.single { it.item.fontFamily == FontFamily.Monospace }
assertThat(monospace.start).isEqualTo(start)
assertThat(monospace.end).isEqualTo(end)
val codeAnnotations = paragraph.text.getStringAnnotations(HtmlMessageParser.INLINE_CODE_ANNOTATION_TAG, start, end)
assertThat(codeAnnotations).hasSize(1)
}
@Test
fun `pre becomes a code block with raw text, and its inner code is not treated as inline code`() {
val document = parse("<pre><code>val x = 1\nval y = 2</code></pre>")
val codeBlock = document.children.single() as CodeBlockNode
assertThat(codeBlock.code).isEqualTo("val x = 1\nval y = 2")
}
@Test
fun `a regular link is styled and annotated with its url`() {
val permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(android.net.Uri.parse(it)) }
val document = parse("""Visit <a href="https://element.io">the site</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.inlineContent).isEmpty()
val linkStart = paragraph.text.text.indexOf("the site")
val annotations = paragraph.text.getStringAnnotations(
tag = HtmlMessageParser.LINK_ANNOTATION_TAG,
start = linkStart,
end = linkStart + "the site".length,
)
assertThat(annotations.single().item).isEqualTo("https://element.io")
}
@Test
fun `a user link becomes a mention pill`() {
val userId = UserId("@alice:example.org")
val permalinkParser = FakePermalinkParser { PermalinkData.UserLink(userId) }
val document = parse("""Hi <a href="https://matrix.to/#/@alice:example.org">Alice</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
val mention = paragraph.inlineContent.values.single()
assertThat(mention).isEqualTo(MentionNodeContent.User(displayText = "Alice", userId = userId))
// The placeholder id in the text matches the map key.
val placeholderId = paragraph.inlineContent.keys.single()
val annotations = paragraph.text.getStringAnnotations(0, paragraph.text.length)
assertThat(annotations.any { it.item == placeholderId }).isTrue()
}
@Test
fun `a room link becomes a room mention pill`() {
val roomIdOrAlias = RoomAlias("#room:example.org").toRoomIdOrAlias()
val permalinkParser = FakePermalinkParser {
PermalinkData.RoomLink(roomIdOrAlias = roomIdOrAlias)
}
val document = parse("""See <a href="https://matrix.to/#/#room:example.org">the room</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
val mention = paragraph.inlineContent.values.single()
assertThat(mention).isEqualTo(
MentionNodeContent.Room(displayText = "the room", roomIdOrAlias = roomIdOrAlias)
)
}
@Test
fun `a link to an event is a regular link, not a mention`() {
val permalinkParser = FakePermalinkParser {
PermalinkData.RoomLink(
roomIdOrAlias = RoomId("!room:example.org").toRoomIdOrAlias(),
eventId = EventId("\$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg"),
)
}
val document = parse("""Look <a href="https://matrix.to/#/!room:example.org/event">here</a>""", permalinkParser)
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.inlineContent).isEmpty()
}
@Test
fun `mx-reply content is stripped`() {
val document = parse("<mx-reply>In reply to something</mx-reply>Actual message")
val paragraph = document.children.single() as ParagraphNode
assertThat(paragraph.text.text).isEqualTo("Actual message")
}
@Test
fun `blank input produces an empty document`() {
val document = parse(" ")
assertThat(document.children).isEmpty()
}
private fun parse(
html: String,
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = DefaultHtmlMessageParser(permalinkParser).parse(Jsoup.parse(html))
}