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,19 @@
/*
* 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.api"
}
dependencies {
api(projects.libraries.matrix.api)
api(libs.jsoup)
}
@@ -0,0 +1,40 @@
/*
* 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.api
import org.jsoup.nodes.Document
/**
* Parses the HTML [Document] of a formatted message body into a tree of [MessageNode]s
* that a Compose renderer can turn into composables.
*
* The [Document] is expected to have already been sanitized (e.g. via
* `FormattedBody.toHtmlDocument`) so that only supported tags remain.
*/
interface HtmlMessageParser {
/**
* Converts [document] into a [DocumentNode]. Never throws: unknown or empty content
* yields a [DocumentNode] with no children.
*/
fun parse(document: Document): DocumentNode
companion object {
/**
* Tag used for the string annotation attached to link (`<a href>`) text ranges in a
* [ParagraphNode.text]. The annotation value is the link target URL.
*/
const val LINK_ANNOTATION_TAG = "url"
/**
* Tag used for the string annotation attached to inline code (`<code>`) text ranges in a
* [ParagraphNode.text]. The annotation value is empty; only the range matters. A `<code>`
* nested inside a `<pre>` is part of the [CodeBlockNode] and is never annotated this way.
*/
const val INLINE_CODE_ANNOTATION_TAG = "inline_code"
}
}
@@ -0,0 +1,42 @@
/*
* 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.api
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
/**
* Describes a mention "pill" referenced from a [ParagraphNode].
*
* Each mention occupies an inline-content placeholder inside the paragraph's
* [ParagraphNode.text]; the renderer looks the id up in [ParagraphNode.inlineContent]
* and draws the corresponding pill.
*/
@Immutable
sealed interface MentionNodeContent {
/** The text shown inside the pill (e.g. a display name, prefixed with `@` for users). */
val displayText: String
/** A mention of a specific user (`<a href="matrix.to/#/@user:server">`). */
data class User(
override val displayText: String,
val userId: UserId,
) : MentionNodeContent
/** A mention of a room (`<a href="matrix.to/#/#room:server">`). */
data class Room(
override val displayText: String,
val roomIdOrAlias: RoomIdOrAlias,
) : MentionNodeContent
/** An `@room` mention notifying everyone in the room. */
data class Everyone(
override val displayText: String,
) : MentionNodeContent
}
@@ -0,0 +1,95 @@
/*
* 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.api
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
/**
* A node in the parsed representation of a formatted (HTML) message body.
*
* The tree is produced by [HtmlMessageParser] and consumed by a Compose renderer.
* Block-level nodes ([BlockNode]) either contain other block nodes (nested lists, a
* quote containing paragraphs, ...) or terminate in a [ParagraphNode] / [CodeBlockNode]
* that carries the actual text.
*/
@Immutable
sealed interface MessageNode
/** A block-level node, laid out vertically by the renderer. */
@Immutable
sealed interface BlockNode : MessageNode
/**
* The root of a parsed message: an ordered list of block-level children.
*/
@Immutable
data class DocumentNode(
val children: ImmutableList<BlockNode>,
) : BlockNode
/**
* A run of inline content (text with inline formatting) already flattened to an
* [AnnotatedString]. This is the only leaf that carries styled text.
*
* Inline formatting (`<b>`, `<i>`, `<u>`, `<del>`, inline `<code>`) is encoded as
* [androidx.compose.ui.text.SpanStyle] ranges, and links (`<a>`) as string annotations
* tagged [HtmlMessageParser.LINK_ANNOTATION_TAG].
*
* Mentions (pills) are encoded as inline-content placeholders inside [text] (see
* [androidx.compose.foundation.text.appendInlineContent]); [inlineContent] maps each
* placeholder id to the [MentionNodeContent] the renderer needs to draw the pill.
*/
@Immutable
data class ParagraphNode(
val text: AnnotatedString,
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
) : BlockNode
/**
* A preformatted code block (`<pre>`). Rendered in a monospaced, horizontally
* scrollable container. The text is kept raw: no inline styling is applied inside it.
*/
@Immutable
data class CodeBlockNode(
val code: String,
) : BlockNode
/**
* A block quote (`<blockquote>`). Quotes can nest and can contain any block content.
*/
@Immutable
data class QuoteNode(
val children: ImmutableList<BlockNode>,
) : BlockNode
/**
* An ordered (`<ol>`) or unordered (`<ul>`) list.
*
* @param ordered whether the list is numbered (`<ol>`) or bulleted (`<ul>`).
* @param startIndex the number the first item of an ordered list starts at (honors `ol[start]`, default 1).
* @param items the list items, in document order.
*/
@Immutable
data class ListNode(
val ordered: Boolean,
val startIndex: Int,
val items: ImmutableList<ListItemNode>,
) : BlockNode
/**
* A single list item (`<li>`). Being a block container, an item can itself hold
* paragraphs, nested lists or quotes.
*/
@Immutable
data class ListItemNode(
val children: ImmutableList<BlockNode>,
) : MessageNode
@@ -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))
}
@@ -0,0 +1,18 @@
/*
* 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-library")
}
android {
namespace = "io.element.android.libraries.htmlrenderer.test"
}
dependencies {
implementation(projects.libraries.htmlrenderer.api)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,19 @@
/*
* 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.test
import io.element.android.libraries.htmlrenderer.api.DocumentNode
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
import io.element.android.tests.testutils.lambda.lambdaError
import org.jsoup.nodes.Document
class FakeHtmlMessageParser(
private val parseResult: (Document) -> DocumentNode = { lambdaError() },
) : HtmlMessageParser {
override fun parse(document: Document): DocumentNode = parseResult(document)
}
+1
View File
@@ -16,5 +16,6 @@ android {
dependencies {
implementation(libs.appyx.core)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.ui.common.layout
import android.text.Layout
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* A layout with 2 children: the [content] and the [overlay].
*
* It will try to place the [overlay] on top of the [content] if possible, avoiding the area of it that is non-overlapping.
* If the [overlay] can't be placed on top of the [content], it will be placed to the right of it, if it fits, otherwise, to its bottom in a new row.
*
* @param overlay The 'overlay' component of the layout, which will be positioned relative to the [content].
* @param modifier The modifier for the layout.
* @param spacing The spacing between the [content] and the [overlay]. Defaults to `0.dp`.
* @param overlayOffset The offset of the [overlay] from the bottom right corner of the [content].
* @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`.
* @param content The 'content' component of the layout.
*/
@Suppress("ContentSlotReused") // Since we added an exception for `LocalUiTestMode`, detekt thinks the layout can change in runtime: it won't
@Composable
fun ContentAvoidingLayout(
overlay: @Composable () -> Unit,
modifier: Modifier = Modifier,
spacing: Dp = 0.dp,
overlayOffset: DpOffset = DpOffset.Zero,
shrinkContent: Boolean = false,
content: @Composable ContentAvoidingLayoutScope.() -> Unit,
) {
val scope = remember { ContentAvoidingLayoutScopeInstance() }
// Custom layouts don't seem to work well with Compose UI tests (they crash), so we use a Column instead when running in test mode.
if (LocalUiTestMode.current) {
Column {
scope.content()
overlay()
}
return
}
Layout(
modifier = modifier,
content = {
scope.content()
overlay()
}
) { measurables, constraints ->
// Measure the `overlay` view first, in case we need to shrink the `content`
val overlayPlaceable = measurables.last().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth))
val contentConstraints = if (shrinkContent) {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width)
} else {
Constraints(minWidth = 0, maxWidth = constraints.maxWidth)
}
val contentPlaceable = measurables.first().measure(contentConstraints)
var layoutWidth = contentPlaceable.width
var layoutHeight = contentPlaceable.height
val data = scope.data.value
// Free space = width of the whole component - width of its non overlapping contents
val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0)
when {
// When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row
!data.canOverlay() || !shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> {
layoutHeight += overlayPlaceable.height + overlayOffset.y.roundToPx()
}
// If the content is smaller than the available max width, we can move the overlay to the right of the content
contentPlaceable.width < constraints.maxWidth -> {
// If both the content and the overlay plus the padding can fit inside the current layoutWidth, there is no need to increase it
if (freeSpace < overlayPlaceable.width + spacing.roundToPx()) {
// Otherwise, we need to increase it by the width of the overlay + some padding adjustments
val calculatedWidth = max(data.nonOverlappingContentWidth + overlayPlaceable.width + spacing.roundToPx(), contentPlaceable.width)
layoutWidth = min(calculatedWidth, constraints.maxWidth)
}
}
else -> Unit
}
layoutWidth = max(layoutWidth, constraints.minWidth)
layoutHeight = max(layoutHeight, constraints.minHeight)
layout(layoutWidth, layoutHeight) {
contentPlaceable.placeRelative(0, 0)
overlayPlaceable.placeRelative(layoutWidth - overlayPlaceable.width, layoutHeight - overlayPlaceable.height + overlayOffset.y.roundToPx())
}
}
}
/**
* Data class to hold the content layout data.
* This is used to pass the data from the content to the [ContentAvoidingLayout].
*
* @param contentWidth The full width of the content in pixels.
* @param contentHeight The full height of the content in pixels.
* @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp.
* @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
*/
data class ContentAvoidingLayoutData(
val contentWidth: Int = 0,
val contentHeight: Int = 0,
val nonOverlappingContentWidth: Int = contentWidth,
val nonOverlappingContentHeight: Int = contentHeight,
) {
fun canOverlay(): Boolean = contentWidth != Int.MAX_VALUE
companion object {
val NotOverlapping = ContentAvoidingLayoutData(contentWidth = Int.MAX_VALUE, contentHeight = Int.MAX_VALUE)
}
}
/**
* A scope for the [ContentAvoidingLayout].
*/
interface ContentAvoidingLayoutScope {
/**
* It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly.
*/
fun onContentLayoutChange(data: ContentAvoidingLayoutData)
}
private class ContentAvoidingLayoutScopeInstance(
val data: MutableState<ContentAvoidingLayoutData> = mutableStateOf(ContentAvoidingLayoutData()),
) : ContentAvoidingLayoutScope {
override fun onContentLayoutChange(data: ContentAvoidingLayoutData) {
this.data.value = data
}
}
object ContentAvoidingLayout {
/**
* Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of a Text based component.
*/
@Composable
fun measureLastTextLine(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((TextLayoutResult) -> Unit) {
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: TextLayoutResult ->
// We need to add the external extra width so it's not taken into account as 'free space'
val textDirection = tryOrNull { textLayout.getParagraphDirection(0) }
val lastLineWidth = when (textDirection) {
ResolvedTextDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
else -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
}
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
onContentLayoutChange(
ContentAvoidingLayoutData(
contentWidth = textLayout.size.width + extraWidthPx,
contentHeight = textLayout.size.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
/**
* Measures the last line of a [Layout] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData].
*
* This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component.
*/
@Composable
fun measureLegacyLastTextLine(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((Layout) -> Unit) {
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: Layout ->
// We need to add the external extra width so it's not taken into account as 'free space'
val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt()
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1)
onContentLayoutChange(
ContentAvoidingLayoutData(
contentWidth = textLayout.width + extraWidthPx,
contentHeight = textLayout.height,
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
nonOverlappingContentHeight = lastLineHeight,
)
)
}
}
}