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