mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
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:
committed by
GitHub
parent
97d9707d6a
commit
c0bb68c872
+237
@@ -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_"
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+362
@@ -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
|
||||
+212
@@ -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"
|
||||
}
|
||||
}
|
||||
+204
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user