mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 20:01:55 +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
@@ -59,6 +59,7 @@ dependencies {
|
||||
implementation(projects.libraries.voicerecorder.api)
|
||||
implementation(projects.libraries.mediaplayer.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.libraries.uiCommon)
|
||||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.wellknown.api)
|
||||
|
||||
+1
-1
@@ -36,7 +36,6 @@ import io.element.android.features.messages.impl.timeline.components.TimelineIte
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
@@ -58,6 +57,7 @@ import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.compose.LocalAnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
|
||||
+2
-2
@@ -72,8 +72,6 @@ import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
@@ -139,6 +137,8 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName
|
||||
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
|
||||
+1
-1
@@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.aRedactedMessagesGroup
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup
|
||||
@@ -35,6 +34,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -29,7 +29,6 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
|
||||
@@ -46,6 +45,7 @@ import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
+2
-2
@@ -22,12 +22,12 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
|
||||
/**
|
||||
* package-private, you should only use TimelineItemFileView and TimelineItemAudioView.
|
||||
|
||||
+1
-1
@@ -12,11 +12,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentPreviewParam
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
|
||||
@Composable
|
||||
fun TimelineItemAudioView(
|
||||
|
||||
+1
-1
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContentPreviewParam
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
@@ -20,6 +19,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
|
||||
+2
-2
@@ -24,8 +24,6 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAttachmentsContent
|
||||
@@ -60,6 +58,8 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundCo
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
+1
-1
@@ -13,11 +13,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentPreviewParam
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -22,12 +22,12 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
|
||||
@Composable
|
||||
fun TimelineItemInformativeView(
|
||||
|
||||
+1
-1
@@ -11,11 +11,11 @@ package io.element.android.features.messages.impl.timeline.components.event
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
|
||||
+2
-2
@@ -23,8 +23,6 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam
|
||||
@@ -36,6 +34,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
|
||||
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
||||
|
||||
+1
-1
@@ -11,11 +11,11 @@ package io.element.android.features.messages.impl.timeline.components.event
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -40,7 +40,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentPreviewParam
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
|
||||
@@ -52,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
+95
@@ -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)
|
||||
}
|
||||
+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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+19
@@ -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)
|
||||
}
|
||||
@@ -16,5 +16,6 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(libs.appyx.core)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
}
|
||||
|
||||
+12
-7
@@ -1,12 +1,12 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* 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.features.messages.impl.timeline.components.layout
|
||||
package io.element.android.libraries.ui.common.layout
|
||||
|
||||
import android.text.Layout
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,7 +25,6 @@ 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 io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
@@ -91,7 +90,7 @@ fun ContentAvoidingLayout(
|
||||
|
||||
when {
|
||||
// When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row
|
||||
!shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> {
|
||||
!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
|
||||
@@ -130,7 +129,13 @@ data class ContentAvoidingLayoutData(
|
||||
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].
|
||||
@@ -157,7 +162,7 @@ object ContentAvoidingLayout {
|
||||
* This is supposed to be used in the `onTextLayout` parameter of a Text based component.
|
||||
*/
|
||||
@Composable
|
||||
internal fun measureLastTextLine(
|
||||
fun measureLastTextLine(
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
extraWidth: Dp = 0.dp,
|
||||
): ((TextLayoutResult) -> Unit) {
|
||||
@@ -187,7 +192,7 @@ object ContentAvoidingLayout {
|
||||
* This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component.
|
||||
*/
|
||||
@Composable
|
||||
internal fun measureLegacyLastTextLine(
|
||||
fun measureLegacyLastTextLine(
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
extraWidth: Dp = 0.dp,
|
||||
): ((Layout) -> Unit) {
|
||||
@@ -124,6 +124,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||
implementation(project(":libraries:oauth:impl"))
|
||||
implementation(project(":libraries:workmanager:impl"))
|
||||
implementation(project(":libraries:emoji:impl"))
|
||||
implementation(project(":libraries:htmlrenderer:impl"))
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.allServicesImpl() {
|
||||
|
||||
@@ -67,6 +67,7 @@ class KonsistClassNameTest {
|
||||
"LoginModeViewErrorPreviewParam",
|
||||
"OverlapRatioPreviewParam",
|
||||
"TextFileContentPreviewParam",
|
||||
"HtmlMessageContentPreviewParam",
|
||||
)
|
||||
.also {
|
||||
// Check that classes are actually found
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7c88b3ed8cc69dee59d4d0a726a9e383d00f2815b6525315f7dcd96f9c25ec2a
|
||||
size 45618
|
||||
oid sha256:22ea7fe121a63b35a2c2bb47609e692f8bf2b913871a6baf3d420c04334c6c4a
|
||||
size 44344
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user