`) text ranges in a
+ * [ParagraphNode.text]. The annotation value is empty; only the range matters. A ``
+ * nested inside a `` is part of the [CodeBlockNode] and is never annotated this way.
+ */
+ const val INLINE_CODE_ANNOTATION_TAG = "inline_code"
+ }
+}
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt
new file mode 100644
index 0000000000..4886dcef23
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MentionNodeContent.kt
@@ -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 (``). */
+ data class User(
+ override val displayText: String,
+ val userId: UserId,
+ ) : MentionNodeContent
+
+ /** A mention of a room (``). */
+ 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
+}
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt
new file mode 100644
index 0000000000..0b28b0cd1a
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/MessageNode.kt
@@ -0,0 +1,107 @@
+/*
+ * 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
+
+/**
+ * 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 (``, ``, ``, ``, inline ``) is encoded as
+ * [androidx.compose.ui.text.SpanStyle] ranges, and links (``) 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 = persistentMapOf(),
+) : BlockNode
+
+/**
+ * A heading (``–``). Like a [ParagraphNode] it carries inline content ([text] and
+ * [inlineContent]), plus the heading [level] from 1 (largest) to 6 (smallest), used by the renderer
+ * to pick the typography.
+ */
+@Immutable
+data class HeaderNode(
+ val level: Int,
+ val text: AnnotatedString,
+ val inlineContent: ImmutableMap = persistentMapOf(),
+) : BlockNode
+
+/**
+ * A preformatted code block (``). 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 (``). Quotes can nest and can contain any block content.
+ */
+@Immutable
+data class QuoteNode(
+ val children: ImmutableList,
+) : BlockNode
+
+/**
+ * An ordered (``) or unordered (``) list.
+ *
+ * @param ordered whether the list is numbered (``) or bulleted (``).
+ * @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,
+) : BlockNode
+
+/**
+ * A single list item (`- `). Being a block container, an item can itself hold
+ * paragraphs, nested lists or quotes.
+ */
+@Immutable
+data class ListItemNode(
+ val children: ImmutableList
,
+) : MessageNode
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt
new file mode 100644
index 0000000000..1f3010660c
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/spans/InlineCodeSpanStyle.kt
@@ -0,0 +1,20 @@
+/*
+ * 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.spans
+
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontFamily
+import io.element.android.compound.theme.ElementTheme
+
+/**
+ * The style for inline code spans.
+ */
+val InlineCodeSpanStyle: SpanStyle = SpanStyle(
+ fontFamily = FontFamily.Monospace,
+ fontSize = ElementTheme.typography.fontBodySmRegular.fontSize,
+)
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt
new file mode 100644
index 0000000000..9bba8807af
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/ContentAvoidingHtmlMessageContentPreview.kt
@@ -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.api.ui
+
+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,
+ )
+ }
+ )
+ }
+}
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt
new file mode 100644
index 0000000000..e113856cf7
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt
@@ -0,0 +1,606 @@
+/*
+ * 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.ui
+
+import android.content.ClipData
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+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.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
+import androidx.compose.ui.input.pointer.changedToUp
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.stringResource
+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.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Density
+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.components.linkify
+import io.element.android.libraries.designsystem.text.toPx
+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.HeaderNode
+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 io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.toImmutableMap
+import kotlinx.coroutines.launch
+
+/**
+ * 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 the [Modifier] to be applied to the layout.
+ * @param currentUserId the current user ID, if known. This can be used to display UI sent by/owned by the current user differently.
+ * @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: (url: String, text: String) -> Unit = { _, _ -> },
+ onLinkLongClick: (url: String, text: 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,
+ )
+ }
+ 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: (url: String, text: String) -> Unit,
+ val onLinkLongClick: (url: String, text: 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
+ is HeaderNode -> this
+}
+
+@Composable
+private fun BlockNodes(
+ nodes: ImmutableList,
+ 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 && node !is HeaderNode) {
+ 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 HeaderNode -> HeaderView(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,
+) {
+ // 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
+ }
+ TextBlock(
+ text = node.text,
+ inlineContent = node.inlineContent,
+ textStyle = ElementTheme.typography.fontBodyMdRegular,
+ context = context,
+ modifier = modifier,
+ onTextLayout = { measureLastLine?.invoke(it) },
+ )
+}
+
+@Composable
+private fun HeaderView(
+ node: HeaderNode,
+ context: RenderContext,
+ modifier: Modifier = Modifier,
+) {
+ // 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
+ }
+ TextBlock(
+ text = node.text,
+ inlineContent = node.inlineContent,
+ textStyle = headerTextStyle(node.level),
+ context = context,
+ modifier = modifier,
+ onTextLayout = { measureLastLine?.invoke(it) }
+ )
+}
+
+/**
+ * Renders an inline-content block (a paragraph or a header) as a [Text] with [textStyle], applying
+ * link styling, the inline-code background boxes, mention/image inline content and link taps.
+ */
+@Composable
+private fun TextBlock(
+ text: AnnotatedString,
+ inlineContent: ImmutableMap,
+ textStyle: TextStyle,
+ context: RenderContext,
+ modifier: Modifier = Modifier,
+ onTextLayout: (TextLayoutResult) -> Unit = {},
+) {
+ val linkColor = ElementTheme.colors.textLinkExternal
+ val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
+ val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
+ val styledText = remember(text, linkColor) {
+ text.linkify(SpanStyle(color = linkColor))
+ .applyLinkStyles(linkColor = linkColor)
+ }
+ val resolvedInlineContent = rememberMentionInlineContent(inlineContent, context)
+ val layoutResult = remember { mutableStateOf(null) }
+ Text(
+ text = styledText,
+ modifier = modifier
+ .drawInlineCodeBackgrounds(styledText, layoutResult, codeBackgroundColor, codeBorderColor, LocalDensity.current)
+ .linkTapHandler(styledText, layoutResult, context),
+ style = textStyle,
+ color = ElementTheme.colors.textPrimary,
+ inlineContent = resolvedInlineContent,
+ onTextLayout = { result ->
+ layoutResult.value = result
+ onTextLayout(result)
+ },
+ )
+}
+
+/** Maps an ``–`` [level] to a Compound typography style, largest first. */
+@Composable
+private fun headerTextStyle(level: Int): TextStyle = when (level) {
+ // For safety reasons, use the same style for H1 and H2, to avoid people using H1 for 'shouting' spam that fills the timeline with huge text
+ 1, 2 -> ElementTheme.typography.fontHeadingLgBold
+ 3 -> ElementTheme.typography.fontHeadingMdBold
+ 4 -> ElementTheme.typography.fontHeadingSmMedium
+ 5 -> ElementTheme.typography.fontBodyLgMedium
+ else -> ElementTheme.typography.fontBodyMdMedium
+}
+
+@Composable
+private fun CodeBlockView(
+ node: CodeBlockNode,
+ modifier: Modifier = Modifier,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val scrollState = rememberScrollState()
+ val localClipboard = LocalClipboard.current
+ val clipDataLabel = stringResource(CommonStrings.common_code_block)
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(8.dp))
+ .border(width = 1.dp, color = ElementTheme.colors.borderInteractiveSecondary, shape = RoundedCornerShape(8.dp))
+ .background(ElementTheme.colors.bgSubtleSecondary)
+ .height(IntrinsicSize.Min)
+ .combinedClickable(
+ onClick = {},
+ onLongClick = {
+ coroutineScope.launch {
+ localClipboard.setClipEntry(ClipEntry(ClipData.newPlainText(clipDataLabel, node.code)))
+ }
+ },
+ onLongClickLabel = stringResource(CommonStrings.action_copy_to_clipboard),
+ ),
+ ) {
+ Text(
+ modifier = Modifier.horizontalScroll(scrollState).padding(horizontal = 12.dp, vertical = 8.dp),
+ text = node.code,
+ style = ElementTheme.typography.fontBodySmRegular.copy(fontFamily = FontFamily.Monospace),
+ color = ElementTheme.colors.textPrimary,
+ softWrap = false,
+ )
+
+ // Previews and screenshots can't render the fading edge gradients properly, everything is obscured by them, so we skip them in the preview mode.
+ if (LocalInspectionMode.current.not() && (scrollState.canScrollForward || scrollState.canScrollBackward)) {
+ val progress by remember {
+ derivedStateOf {
+ scrollState.value.toFloat() / scrollState.maxValue
+ }
+ }
+
+ val alpha by animateFloatAsState(targetValue = progress, label = "CodeBlockViewScrollAlpha")
+
+ // Draw a fading edge gradient at the left and right of the code block, to hint that it is horizontally scrollable.
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(
+ brush = Brush.horizontalGradient(
+ colors = listOf(ElementTheme.colors.bgSubtleTertiary.copy(alpha = alpha), Color.Transparent),
+ startX = 0f,
+ endX = CodeBlockFadingEdgeWidth.toPx(),
+ )
+ )
+ )
+
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(
+ brush = Brush.horizontalGradient(
+ colors = listOf(Color.Transparent, ElementTheme.colors.bgSubtleTertiary.copy(alpha = 1f - alpha)),
+ startX = scrollState.viewportSize - CodeBlockFadingEdgeWidth.toPx(),
+ endX = scrollState.viewportSize.toFloat(),
+ )
+ )
+ )
+ }
+ }
+}
+
+@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),
+ textAlign = if (node.ordered) TextAlign.Start else TextAlign.Center,
+ )
+ 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,
+ context: RenderContext,
+): ImmutableMap {
+ 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 the theme-dependent link color onto the link-annotated ranges. */
+private fun AnnotatedString.applyLinkStyles(linkColor: Color): AnnotatedString {
+ val linkRanges = getStringAnnotations(LINK_ANNOTATION_TAG, 0, length)
+ if (linkRanges.isEmpty()) return this
+ return buildAnnotatedString {
+ append(this@applyLinkStyles)
+ linkRanges.forEach { linkRange ->
+ val containsColorSpan =
+ spanStyles.any { colorRange -> colorRange.start == linkRange.start && colorRange.end == linkRange.end && colorRange.item.color == linkColor }
+ if (!containsColorSpan) {
+ addStyle(SpanStyle(color = linkColor), linkRange.start, linkRange.end)
+ }
+ }
+ }
+}
+
+/**
+ * Draws a rounded, bordered box behind each inline code ([INLINE_CODE_ANNOTATION_TAG]) range, so
+ * inline code matches the look of [CodeBlockView] but only covers the annotated text. A range that
+ * wraps across several lines gets one box per line.
+ */
+private fun Modifier.drawInlineCodeBackgrounds(
+ text: AnnotatedString,
+ layoutResult: State,
+ backgroundColor: Color,
+ borderColor: Color,
+ density: Density,
+): Modifier = drawBehind {
+ val layout = layoutResult.value ?: return@drawBehind
+ val codeRanges = text.getStringAnnotations(INLINE_CODE_ANNOTATION_TAG, 0, text.length)
+ if (codeRanges.isEmpty()) return@drawBehind
+ val strokeWidth = InlineCodeBorderWidth.toPx()
+ val horizontalPadding = InlineCodeHorizontalPadding.toPx()
+ codeRanges.forEach { range ->
+ val firstLine = layout.getLineForOffset(range.start)
+ val lastLine = layout.getLineForOffset(range.end)
+ for (line in firstLine..lastLine) {
+ val lineStart = maxOf(range.start, layout.getLineStart(line))
+ val lineEnd = minOf(range.end, layout.getLineEnd(line, visibleEnd = true))
+ if (lineEnd <= lineStart) continue
+ val startX = layout.getHorizontalPosition(lineStart, usePrimaryDirection = true)
+ val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true)
+ val left = minOf(startX, endX) - horizontalPadding
+ val right = maxOf(startX, endX) + horizontalPadding
+ val topLeft = Offset(left, layout.getLineTop(line))
+ val size = Size(right - left, layout.getLineBottom(line) - layout.getLineTop(line))
+
+ val shape = RoundedCornerShape(
+ topStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp,
+ topEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp,
+ bottomStart = if (line == firstLine) InlineCodeCornerRadius else 0.dp,
+ bottomEnd = if (line == lastLine) InlineCodeCornerRadius else 0.dp,
+ )
+
+ val outline = shape.createOutline(
+ size = size,
+ layoutDirection = layoutDirection,
+ density = density,
+ )
+
+ translate(topLeft.x, topLeft.y) {
+ drawOutline(outline = outline, color = backgroundColor, style = Fill)
+ drawOutline(outline = outline, color = borderColor, style = Stroke(width = strokeWidth))
+ }
+ }
+ }
+}
+
+/**
+ * Detects taps and long presses on link-annotated ranges ([LINK_ANNOTATION_TAG]) without consuming the touch events if none are detected, and invokes the
+ * appropriate callbacks in [context].
+ */
+private fun Modifier.linkTapHandler(
+ text: AnnotatedString,
+ layoutResult: State,
+ context: RenderContext,
+): Modifier = pointerInput(text) {
+ awaitEachGesture {
+ val down = awaitFirstDown(requireUnconsumed = true)
+ val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture
+ down.consume()
+
+ val longPressTimeout = viewConfiguration.longPressTimeoutMillis
+
+ // Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that.
+ // If we did, it's a tap, otherwise it's a long press.
+ // This warning is a false positive.
+ @Suppress("KotlinConstantConditions")
+ val isLongPress = try {
+ withTimeout(longPressTimeout) {
+ var receivedUp = false
+ while (!receivedUp) {
+ val event = awaitPointerEvent()
+ val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() }
+ if (up != null) {
+ receivedUp = true
+ up.consume()
+ }
+ }
+
+ !receivedUp
+ }
+ } catch (_: PointerEventTimeoutCancellationException) {
+ true
+ }
+
+ if (isLongPress) {
+ context.onLinkLongClick(url, urlText)
+ } else {
+ context.onLinkClick(url, urlText)
+ }
+ }
+}
+
+private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): Pair? {
+ val position = getOffsetForPosition(offset)
+ val urlAnnotation = text.getStringAnnotations(LINK_ANNOTATION_TAG, position, position).firstOrNull() ?: return null
+ return urlAnnotation.item to text.substring(urlAnnotation.start, urlAnnotation.end)
+}
+
+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 = 0.dp
+
+/** Rounded, bordered box drawn behind inline code. */
+private val InlineCodeCornerRadius: Dp = 6.dp
+private val InlineCodeBorderWidth: Dp = 1.dp
+private val InlineCodeHorizontalPadding: Dp = 2.dp
+
+/** The width of the fading edge gradient drawn at the left and right of a [CodeBlockView]. */
+private val CodeBlockFadingEdgeWidth: Dp = 32.dp
diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt
new file mode 100644
index 0000000000..4187acd070
--- /dev/null
+++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContentPreview.kt
@@ -0,0 +1,262 @@
+/*
+ * 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.ui
+
+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.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withAnnotation
+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.HeaderNode
+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.htmlrenderer.api.spans.InlineCodeSpanStyle
+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,
+ currentUserId = UserId("@me:example.org"),
+ modifier = Modifier.padding(16.dp),
+ )
+}
+
+internal class HtmlMessageContentPreviewParam : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ headers(),
+ richParagraph(),
+ inlineCode(),
+ multilineInlineCode(),
+ orderedList(),
+ quote(),
+ nestedQuotes(),
+ codeBlock(),
+ mention(),
+ mentionOwn(),
+ nestedList(),
+ nestedListWithComplexContents(),
+ linkifiedParagraph(),
+ )
+
+ 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 linkifiedParagraph() = document(
+ paragraph(
+ buildAnnotatedString {
+ append("Hello me@matrix.org, check out element.io and and call +1 234 567 8900, then get in touch with ")
+ appendInlineContent(MENTION_ID, "@alice:example.org")
+ append(".\n\n")
+ append("Also, ignore this link: ")
+ val start = length
+ append("https://matrix.org")
+ addStringAnnotation(LINK_ANNOTATION_TAG, "https://matrix.org", start, length)
+ append(" and this one: ")
+ withAnnotation(INLINE_CODE_ANNOTATION_TAG, "") {
+ append("https://element.io")
+ }
+ append(".")
+ },
+ inlineContent = persistentMapOf(
+ MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")),
+ ),
+ ),
+ )
+
+ private fun inlineCode() = document(
+ paragraph(
+ buildAnnotatedString {
+ append("Run ")
+ val start = length
+ withStyle(InlineCodeSpanStyle) { 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(InlineCodeSpanStyle) {
+ 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 mentionOwn() = document(
+ paragraph(
+ text = buildAnnotatedString {
+ append("Hey ")
+ appendInlineContent(MENTION_ID, "@me")
+ append(", welcome!")
+ },
+ inlineContent = persistentMapOf(
+ MENTION_ID to MentionNodeContent.User(displayText = "@me", userId = UserId("@me: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 headers() = document(
+ HeaderNode(level = 1, text = AnnotatedString("Heading 1")),
+ HeaderNode(level = 2, text = AnnotatedString("Heading 2")),
+ HeaderNode(level = 3, text = AnnotatedString("Heading 3")),
+ HeaderNode(level = 4, text = AnnotatedString("Heading 4")),
+ HeaderNode(level = 5, text = AnnotatedString("Heading 5")),
+ HeaderNode(level = 6, text = AnnotatedString("Heading 6")),
+ paragraph(AnnotatedString("Body text under the headings.")),
+ )
+
+ private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList())
+
+ private fun paragraph(
+ text: AnnotatedString,
+ inlineContent: ImmutableMap = persistentMapOf(),
+ ) = ParagraphNode(text = text, inlineContent = inlineContent)
+
+ private companion object {
+ const val MENTION_ID = "mention_0"
+ }
+}
diff --git a/libraries/htmlrenderer/impl/build.gradle.kts b/libraries/htmlrenderer/impl/build.gradle.kts
new file mode 100644
index 0000000000..ba17d855f1
--- /dev/null
+++ b/libraries/htmlrenderer/impl/build.gradle.kts
@@ -0,0 +1,35 @@
+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.matrix.api)
+ implementation(projects.libraries.uiCommon)
+ implementation(libs.jsoup)
+
+ testCommonDependencies(libs)
+ testImplementation(projects.libraries.matrix.test)
+}
diff --git a/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParser.kt b/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParser.kt
new file mode 100644
index 0000000000..5d46920763
--- /dev/null
+++ b/libraries/htmlrenderer/impl/src/main/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParser.kt
@@ -0,0 +1,259 @@
+/*
+ * 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.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.HeaderNode
+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.htmlrenderer.api.spans.InlineCodeSpanStyle
+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.ImmutableMap
+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 {
+ val blocks = mutableListOf()
+ val inlineBuffer = mutableListOf()
+
+ 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 (val tag = 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)
+ in HEADER_TAGS -> buildHeader(element, level = tag.removePrefix("h").toInt())
+ 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()
+ .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): ParagraphNode? {
+ return buildInlineContent(nodes)?.let { ParagraphNode(text = it.text, inlineContent = it.inlineContent) }
+ }
+
+ /** Builds a [HeaderNode] of the given [level] from the inline content of [element], or `null` if empty. */
+ private fun buildHeader(element: Element, level: Int): HeaderNode? {
+ return buildInlineContent(element.childNodes())?.let {
+ HeaderNode(level = level, text = it.text, inlineContent = it.inlineContent)
+ }
+ }
+
+ private fun buildInlineContent(nodes: List): InlineContentBuilder.Result? {
+ 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()
+ 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 `` as monospaced text and annotates the range so the renderer can
+ * apply a code background. A `` inside a `` never reaches here: the `` 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(InlineCodeSpanStyle, 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(): Result? {
+ val text = builder.toAnnotatedString()
+ if (text.isBlank() && mentions.isEmpty()) return null
+ return Result(
+ text = text,
+ inlineContent = mentions.toImmutableMap(),
+ )
+ }
+
+ /** The flattened inline content of a block: styled text plus its inline-content placeholders. */
+ inner class Result(
+ val text: AnnotatedString,
+ val inlineContent: ImmutableMap,
+ )
+ }
+}
+
+/** 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 HEADER_TAGS = setOf("h1", "h2", "h3", "h4", "h5", "h6")
+
+private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL) + HEADER_TAGS
+
+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_"
diff --git a/libraries/htmlrenderer/impl/src/test/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParserTest.kt b/libraries/htmlrenderer/impl/src/test/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParserTest.kt
new file mode 100644
index 0000000000..4c746524c1
--- /dev/null
+++ b/libraries/htmlrenderer/impl/src/test/kotlin/io/element/android/libraries/htmlrenderer/impl/DefaultHtmlMessageParserTest.kt
@@ -0,0 +1,223 @@
+/*
+ * 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.HeaderNode
+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 bold and italic")
+ 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("- First
- Second
")
+ 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("""- Fifth
""")
+ 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("- Parent
- Child
")
+ 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("Quoted
")
+ 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("Deep
")
+ 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 git status 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("val x = 1\nval y = 2
")
+ 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 the site""", 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 Alice""", 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 the room""", 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 here""", permalinkParser)
+ val paragraph = document.children.single() as ParagraphNode
+ assertThat(paragraph.inlineContent).isEmpty()
+ }
+
+ @Test
+ fun `mx-reply content is stripped`() {
+ val document = parse("In reply to something Actual message")
+ val paragraph = document.children.single() as ParagraphNode
+ assertThat(paragraph.text.text).isEqualTo("Actual message")
+ }
+
+ @Test
+ fun `headers become HeaderNodes with their level and inline content`() {
+ val document = parse("Title
Subtitle with bold
Small
")
+ assertThat(document.children).hasSize(3)
+
+ val h1 = document.children[0] as HeaderNode
+ assertThat(h1.level).isEqualTo(1)
+ assertThat(h1.text.text).isEqualTo("Title")
+
+ val h3 = document.children[1] as HeaderNode
+ assertThat(h3.level).isEqualTo(3)
+ assertThat(h3.text.text).isEqualTo("Subtitle with bold")
+
+ val h6 = document.children[2] as HeaderNode
+ assertThat(h6.level).isEqualTo(6)
+ assertThat(h6.text.text).isEqualTo("Small")
+ }
+
+ @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))
+}
diff --git a/libraries/htmlrenderer/test/build.gradle.kts b/libraries/htmlrenderer/test/build.gradle.kts
new file mode 100644
index 0000000000..b44014bbca
--- /dev/null
+++ b/libraries/htmlrenderer/test/build.gradle.kts
@@ -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)
+}
diff --git a/libraries/htmlrenderer/test/src/main/kotlin/io/element/android/libraries/htmlrenderer/test/FakeHtmlMessageParser.kt b/libraries/htmlrenderer/test/src/main/kotlin/io/element/android/libraries/htmlrenderer/test/FakeHtmlMessageParser.kt
new file mode 100644
index 0000000000..5f17517301
--- /dev/null
+++ b/libraries/htmlrenderer/test/src/main/kotlin/io/element/android/libraries/htmlrenderer/test/FakeHtmlMessageParser.kt
@@ -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)
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index 3f98dcc509..e43f362e7c 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher {
*/
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result
+ /**
+ * Returns the latest [RoomInfo] of a room, if known to the client, or `null` if the room is unknown.
+ */
+ suspend fun getRoomInfo(roomId: RoomId): Result
+
/**
* Get a room info flow for a given room ID.
* The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt
index 1758563f55..f4597de640 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.androidutils.metadata.isInDebug
import java.io.Serializable
+@kotlinx.serialization.Serializable
@JvmInline
value class RoomId(val value: String) : Serializable {
init {
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt
index 7a8ec739a5..f27557bda2 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt
@@ -19,4 +19,6 @@ data class MatrixUser(
val avatarUrl: String? = null,
val displayedStatus: DisplayedStatus? = null,
val rawStatus: UserStatus? = null,
+ /** Base64 encoded thumbnail of [avatarUrl], stored locally, used as a fallback when the avatar cannot be loaded. */
+ val avatarThumbnail: String? = null,
) : Parcelable
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index a36184d6bd..d92509b262 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
+import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.room.BaseRoom
@@ -136,6 +137,7 @@ import timber.log.Timber
import java.io.File
import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.io.encoding.Base64
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -143,6 +145,9 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
+// Same value as AVATAR_THUMBNAIL_SIZE_IN_PIXEL in the matrixmedia module
+private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L
+
@Suppress("LargeClass")
class RustMatrixClient(
override val sessionPaths: SessionPaths,
@@ -289,7 +294,11 @@ class RustMatrixClient(
private val ownProfileListener = object : ProfileListener {
override fun onUpdate(profile: UserProfile) {
- _userProfile.tryEmit(profile.map())
+ val matrixUser = profile.map()
+ _userProfile.tryEmit(matrixUser)
+ sessionCoroutineScope.launch {
+ storeUserProfile(matrixUser)
+ }
}
}
@@ -485,13 +494,37 @@ class RustMatrixClient(
.onSuccess { matrixUser ->
// Also update our session storage
_userProfile.emit(matrixUser)
- sessionStore.updateUserProfile(
- sessionId = sessionId.value,
- displayName = matrixUser.displayName,
- avatarUrl = matrixUser.avatarUrl,
- )
+ storeUserProfile(matrixUser)
}
+ /**
+ * Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar,
+ * so that the avatar can be rendered without having to use this client.
+ */
+ private suspend fun storeUserProfile(matrixUser: MatrixUser) {
+ val avatarUrl = matrixUser.avatarUrl
+ val storedSession = sessionStore.getSession(sessionId.value)
+ val avatarData = when {
+ avatarUrl == null -> null
+ // Avatar has not changed, no need to download it again
+ avatarUrl == storedSession?.userAvatarUrl && storedSession.userAvatarData != null -> storedSession.userAvatarData
+ else -> matrixMediaLoader.loadMediaThumbnail(
+ source = MediaSource(avatarUrl),
+ width = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
+ height = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
+ )
+ .map { Base64.encode(it) }
+ .onFailure { Timber.w(it, "Unable to load the avatar thumbnail of the user") }
+ .getOrNull()
+ }
+ sessionStore.updateUserProfile(
+ sessionId = sessionId.value,
+ displayName = matrixUser.displayName,
+ avatarUrl = avatarUrl,
+ avatarData = avatarData,
+ )
+ }
+
override suspend fun searchUsers(searchTerm: String, limit: Long): Result =
withContext(sessionDispatcher) {
runCatchingExceptions {
@@ -779,6 +812,14 @@ class RustMatrixClient(
}
}
+ override suspend fun getRoomInfo(roomId: RoomId): Result = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ innerRoomListService.roomOrNull(roomId.value)?.use { room ->
+ roomInfoMapper.map(room.roomInfo())
+ }
+ }
+ }
+
override fun getRoomInfoFlow(roomId: RoomId): Flow> {
return mxCallbackFlow {
val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null }
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt
index 6a06ac0599..5a3bb67567 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt
@@ -28,7 +28,6 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
}
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
- is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message)
is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message)
}
is OAuthException -> when (this) {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index 681aa6ba64..1ae2a16d38 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -381,6 +381,9 @@ class RustMatrixAuthenticationService(
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
+ // Start the sync service to ensure that the client receives the encryption updates we'll wait for next.
+ matrixClient.syncService.startSync()
+
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
index 69a76f2825..57f2b8c597 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
@@ -38,4 +38,5 @@ internal fun Session.toSessionData(
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
+ userAvatarData = null,
)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt
index 08bef24722..c42e9c1f02 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt
@@ -8,10 +8,27 @@
package io.element.android.libraries.matrix.impl.media
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaSource
+import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
fun RustMediaSource.map(): MediaSource = use {
MediaSource(it.url(), it.toJson())
}
+
+internal fun MediaSource.toRustMediaSource(): RustMediaSource {
+ val json = this.json
+ return try {
+ if (json != null) {
+ RustMediaSource.fromJson(json)
+ } else {
+ RustMediaSource.fromUrl(safeUrl)
+ }
+ } catch (e: LinkageError) {
+ // Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
+ val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
+ if (isTest) RustMediaSource(NoHandle) else throw e
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
index 7b3a1d9f10..0b8229cb6b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
@@ -20,7 +20,6 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.use
import java.io.File
-import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustMediaLoader(
private val baseCacheDirectory: File,
@@ -80,13 +79,4 @@ class RustMediaLoader(
}
}
}
-
- private fun MediaSource.toRustMediaSource(): RustMediaSource {
- val json = this.json
- return if (json != null) {
- RustMediaSource.fromJson(json)
- } else {
- RustMediaSource.fromUrl(safeUrl)
- }
- }
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
index e892c62e84..17a3910c98 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
@@ -478,7 +478,7 @@ class JoinedRustRoom(
widgetSettings = widgetSettings,
room = innerRoom,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
- override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
+ override suspend fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value)
}
},
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt
index c07ca94f26..60ae423f98 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt
@@ -12,10 +12,9 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.impl.exception.mapClientException
+import io.element.android.libraries.matrix.impl.media.toRustMediaSource
import org.matrix.rustcomponents.sdk.Client
-import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner
-import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustContentScanner(
private val client: Client,
@@ -29,18 +28,3 @@ class RustContentScanner(
}
}
}
-
-private fun MediaSource.toRustMediaSource(): RustMediaSource {
- val json = this.json
- return try {
- if (json != null) {
- RustMediaSource.fromJson(json)
- } else {
- RustMediaSource.fromUrl(safeUrl)
- }
- } catch (e: LinkageError) {
- // Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
- val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
- if (isTest) RustMediaSource(NoHandle) else throw e
- }
-}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
index 3a568761ce..db8f4753ec 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt
@@ -31,7 +31,6 @@ import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.QueueWedgeError
import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.ShieldState
-import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.TimelineEventShieldStateCode
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
@@ -52,7 +51,7 @@ class EventTimelineItemMapper(
isOwn = isOwn,
isRemote = isRemote,
localSendState = localSendState?.map(),
- reactions = (content as? TimelineItemContent.MsgLike)?.content?.reactions.map(),
+ reactions = reactions.map(),
receipts = readReceipts.map(),
sender = UserId(sender),
senderProfile = senderProfile.map(),
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
index b559b7d4d0..4dcb8b1df8 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
@@ -49,7 +49,7 @@ class RustMatrixClientFactoryTest {
fun TestScope.createRustMatrixClientFactory(
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(
- updateUserProfileResult = { _, _, _ -> },
+ updateUserProfileResult = { _, _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
index ea399b39df..4fe61e4d14 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
@@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.data.bytes
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.paths.SessionPaths
+import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
@@ -29,6 +30,7 @@ import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
+import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -39,9 +41,9 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.CreateRoomParameters
+import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.RoomHistoryVisibility
import org.matrix.rustcomponents.sdk.StoreSizes
-import org.matrix.rustcomponents.sdk.UserProfile
import java.io.File
private const val AN_ACCOUNT_DATA_EVENT_TYPE = "org.example.custom"
@@ -76,7 +78,8 @@ class RustMatrixClientTest {
@Test
fun `retrieving the UserProfile updates the database`() = runTest {
val profilePersisted = CompletableDeferred()
- val updateUserProfileResult = lambdaRecorder { _, _, _ -> profilePersisted.complete(Unit) }
+ val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) }
+ val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
@@ -89,24 +92,160 @@ class RustMatrixClientTest {
)
val client = createRustMatrixClient(
client = FakeFfiClient(
- getProfileResult = { userId ->
- UserProfile(
- userId = userId,
- displayName = A_USER_NAME,
- avatarUrl = AN_AVATAR_URL,
- status = null,
- call = null,
- )
- },
+ getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
+ getMediaThumbnailResult = getMediaThumbnailResult,
),
sessionStore = sessionStore,
)
profilePersisted.await()
+ getMediaThumbnailResult.assertions().isCalledOnce()
+ .with(any(), value(240uL), value(240uL))
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(AN_AVATAR_URL),
+ value("AQID"),
+ )
+ client.destroy()
+ }
+
+ @Test
+ fun `retrieving the UserProfile does not download the avatar again if it has not changed`() = runTest {
+ val profilePersisted = CompletableDeferred()
+ val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) }
+ val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) }
+ val sessionStore = InMemorySessionStore(
+ initialList = listOf(
+ aSessionData(
+ sessionId = A_USER_ID.value,
+ userDisplayName = A_USER_NAME,
+ userAvatarUrl = AN_AVATAR_URL,
+ userAvatarData = "storedData",
+ )
+ ),
+ updateUserProfileResult = updateUserProfileResult,
+ )
+ val client = createRustMatrixClient(
+ client = FakeFfiClient(
+ getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
+ getMediaThumbnailResult = getMediaThumbnailResult,
+ ),
+ sessionStore = sessionStore,
+ )
+ profilePersisted.await()
+ getMediaThumbnailResult.assertions().isNeverCalled()
+ updateUserProfileResult.assertions().isCalledOnce()
+ .with(
+ value(A_USER_ID.value),
+ value(A_USER_NAME),
+ value(AN_AVATAR_URL),
+ value("storedData"),
+ )
+ client.destroy()
+ }
+
+ @Test
+ fun `retrieving the UserProfile downloads the avatar again if the previous download failed`() = runTest {
+ val profilePersisted = CompletableDeferred()
+ val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) }
+ val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) }
+ val sessionStore = InMemorySessionStore(
+ initialList = listOf(
+ aSessionData(
+ sessionId = A_USER_ID.value,
+ userDisplayName = A_USER_NAME,
+ userAvatarUrl = AN_AVATAR_URL,
+ userAvatarData = null,
+ )
+ ),
+ updateUserProfileResult = updateUserProfileResult,
+ )
+ val client = createRustMatrixClient(
+ client = FakeFfiClient(
+ getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
+ getMediaThumbnailResult = getMediaThumbnailResult,
+ ),
+ sessionStore = sessionStore,
+ )
+ profilePersisted.await()
+ getMediaThumbnailResult.assertions().isCalledOnce()
+ updateUserProfileResult.assertions().isCalledOnce()
+ .with(
+ value(A_USER_ID.value),
+ value(A_USER_NAME),
+ value(AN_AVATAR_URL),
+ value("AQID"),
+ )
+ client.destroy()
+ }
+
+ @Test
+ fun `retrieving the UserProfile clears the avatar data if the avatar download fails`() = runTest {
+ val profilePersisted = CompletableDeferred()
+ val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) }
+ val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> error("Download failed") }
+ val sessionStore = InMemorySessionStore(
+ initialList = listOf(
+ aSessionData(
+ sessionId = A_USER_ID.value,
+ userDisplayName = A_USER_NAME,
+ userAvatarUrl = "mxc://server.org/previousAvatar",
+ userAvatarData = "previousData",
+ )
+ ),
+ updateUserProfileResult = updateUserProfileResult,
+ )
+ val client = createRustMatrixClient(
+ client = FakeFfiClient(
+ getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
+ getMediaThumbnailResult = getMediaThumbnailResult,
+ ),
+ sessionStore = sessionStore,
+ )
+ profilePersisted.await()
+ getMediaThumbnailResult.assertions().isCalledOnce()
+ updateUserProfileResult.assertions().isCalledOnce()
+ .with(
+ value(A_USER_ID.value),
+ value(A_USER_NAME),
+ value(AN_AVATAR_URL),
+ value(null),
+ )
+ client.destroy()
+ }
+
+ @Test
+ fun `retrieving the UserProfile without avatar clears the avatar data`() = runTest {
+ val profilePersisted = CompletableDeferred()
+ val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) }
+ val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) }
+ val sessionStore = InMemorySessionStore(
+ initialList = listOf(
+ aSessionData(
+ sessionId = A_USER_ID.value,
+ userDisplayName = A_USER_NAME,
+ userAvatarUrl = AN_AVATAR_URL,
+ userAvatarData = "storedData",
+ )
+ ),
+ updateUserProfileResult = updateUserProfileResult,
+ )
+ val client = createRustMatrixClient(
+ client = FakeFfiClient(
+ getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = null) },
+ getMediaThumbnailResult = getMediaThumbnailResult,
+ ),
+ sessionStore = sessionStore,
+ )
+ profilePersisted.await()
+ getMediaThumbnailResult.assertions().isNeverCalled()
+ updateUserProfileResult.assertions().isCalledOnce()
+ .with(
+ value(A_USER_ID.value),
+ value(A_USER_NAME),
+ value(null),
+ value(null),
)
client.destroy()
}
@@ -169,7 +308,7 @@ class RustMatrixClientTest {
private fun TestScope.createRustMatrixClient(
client: Client = FakeFfiClient(),
sessionStore: SessionStore = InMemorySessionStore(
- updateUserProfileResult = { _, _, _ -> },
+ updateUserProfileResult = { _, _, _, _ -> },
),
) = RustMatrixClient(
innerClient = client,
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt
index f18f633889..f4a229b30b 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt
@@ -60,7 +60,7 @@ class RustTemporaryMatrixClientFactoryTest {
private fun TestScope.createRustMatrixClientFactory(
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(
- updateUserProfileResult = { _, _, _ -> },
+ updateUserProfileResult = { _, _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
index d81c6021da..9b589b2281 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
@@ -255,7 +255,7 @@ class RustMatrixAuthenticationServiceTest {
}
private fun TestScope.createRustMatrixAuthenticationService(
- sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }),
+ sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _, _ -> }),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt
index 8689de3c9b..68990560ce 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt
@@ -16,6 +16,7 @@ import org.matrix.rustcomponents.sdk.EventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider
import org.matrix.rustcomponents.sdk.ProfileDetails
+import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.Receipt
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin
@@ -37,6 +38,7 @@ internal fun aRustEventTimelineItem(
readReceipts: Map = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true,
+ reactions: List = emptyList(),
lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(),
) = EventTimelineItem(
isRemote = isRemote,
@@ -55,5 +57,6 @@ internal fun aRustEventTimelineItem(
readReceipts = readReceipts,
origin = origin,
canBeRepliedTo = canBeRepliedTo,
+ reactions = reactions,
lazyProvider = lazyProvider,
)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt
index 0db7590a5c..157adde323 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt
@@ -27,7 +27,6 @@ internal fun aRustTimelineItemContentMsgLike(
mentions = null,
)
),
- reactions = emptyList(),
threadRoot = null,
inReplyTo = null,
threadSummary = null,
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt
index 947e6e7aa1..07997e9109 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt
@@ -16,7 +16,7 @@ import org.matrix.rustcomponents.sdk.UserStatus
internal fun aRustUserProfile(
userId: String = A_USER_ID.value,
displayName: String = "displayName",
- avatarUrl: String = "avatarUrl",
+ avatarUrl: String? = "avatarUrl",
status: UserStatus? = null,
call: UserCall? = null,
) = UserProfile(
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
index 8c6411197a..c2080747d5 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
@@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler
+import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
@@ -70,6 +71,7 @@ class FakeFfiClient(
private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
+ private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() },
private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() },
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
@@ -122,6 +124,10 @@ class FakeFfiClient(
return getProfileResult(userId)
}
+ override suspend fun getMediaThumbnail(mediaSource: MediaSource, width: ULong, height: ULong): ByteArray {
+ return getMediaThumbnailResult(mediaSource, width, height)
+ }
+
override suspend fun homeserverLoginDetails(): HomeserverLoginDetails {
return homeserverLoginDetailsResult()
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index 6ba42270fe..f176d3b006 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import java.io.File
import java.util.Optional
+import kotlin.jvm.optionals.getOrNull
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
@@ -173,6 +174,7 @@ class FakeMatrixClient(
var knockRoomLambda: (RoomIdOrAlias, String, List) -> Result = { _, _, _ ->
Result.success(null)
}
+ var getRoomInfoLambda = { _: RoomId -> Result.success(null) }
var getRoomInfoFlowLambda = { _: RoomId ->
flowOf>(Optional.empty())
}
@@ -379,6 +381,8 @@ class FakeMatrixClient(
return Result.success(visitedRoomsId)
}
+ override suspend fun getRoomInfo(roomId: RoomId): Result = getRoomInfoLambda(roomId)
+
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt
index 8a833cee44..68c53c0685 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt
@@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
-import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
+import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
@@ -87,7 +87,7 @@ fun SpaceHeaderView(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (alias != null) {
- RoomPreviewAliasAtom(alias = alias.value)
+ CopiableTextAtom(text = alias.value)
}
SpaceInfoRow(visibility = visibility)
}
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
index 2f52320b0d..5cb3535b06 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt
@@ -89,6 +89,12 @@ private object CustomHtmlToDomParser {
"blockquote",
"p",
"br",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
// Add custom `mx-reply` tag, even if it's just to remove its contents from the plain text version of the message
"mx-reply"
)
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt
index dd8655af26..a6798a377b 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt
@@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.ui.model
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
@@ -20,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.R
+@Immutable
data class InviteSender(
val userId: UserId,
val displayName: String,
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt
index 83a707347d..ac3cdf0e3b 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt
@@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
name = displayName,
url = avatarUrl,
size = size,
+ thumbnail = avatarThumbnail,
)
fun MatrixUser.getBestName(): String {
diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt
index 035548a4ec..ae8a04b7cd 100644
--- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt
+++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt
@@ -33,12 +33,14 @@ class MatrixUserExtensionsTest : RobolectricTest() {
userId = A_USER_ID,
displayName = "displayName",
avatarUrl = "avatarUrl",
+ avatarThumbnail = "avatarThumbnail",
)
val expected = AvatarData(
id = A_USER_ID.value,
name = "displayName",
url = "avatarUrl",
size = AvatarSize.UserHeader,
+ thumbnail = "avatarThumbnail",
)
assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected)
}
diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt
index 37f77d11f8..36c3ba99fb 100644
--- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt
+++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt
@@ -100,9 +100,9 @@ class DefaultMediaSender(
inReplyToEventId: EventId?,
): Result {
val mediaLogId = mediaId(mediaUploadInfo.file)
- return getTimeline().flatMap {
- Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}")
- it.sendMedia(
+ return withTimeline { timeline ->
+ Timber.d("Started sending media $mediaLogId using timeline: ${timeline.mode}")
+ timeline.sendMedia(
uploadInfo = mediaUploadInfo,
caption = caption,
formattedCaption = formattedCaption,
@@ -128,12 +128,14 @@ class DefaultMediaSender(
mediaOptimizationConfig = mediaOptimizationConfig,
)
.flatMapCatching { info ->
- getTimeline().getOrThrow().sendMedia(
- uploadInfo = info,
- caption = caption,
- formattedCaption = formattedCaption,
- inReplyToEventId = inReplyToEventId,
- )
+ withTimeline { timeline ->
+ timeline.sendMedia(
+ uploadInfo = info,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ inReplyToEventId = inReplyToEventId,
+ )
+ }
}
.handleSendResult(mediaId(uri))
}
@@ -158,12 +160,14 @@ class DefaultMediaSender(
audioInfo = audioInfo,
waveform = waveForm,
)
- getTimeline().getOrThrow().sendMedia(
- uploadInfo = newInfo,
- caption = null,
- formattedCaption = null,
- inReplyToEventId = inReplyToEventId,
- )
+ withTimeline { timeline ->
+ timeline.sendMedia(
+ uploadInfo = newInfo,
+ caption = null,
+ formattedCaption = null,
+ inReplyToEventId = inReplyToEventId,
+ )
+ }
}
.handleSendResult(mediaId(uri))
}
@@ -176,7 +180,7 @@ class DefaultMediaSender(
): Result {
val galleryLogId = "gallery[${mediaUploadInfos.size} items]"
Timber.d("Sending $galleryLogId")
- return getTimeline().flatMap { timeline ->
+ return withTimeline { timeline ->
val galleryItems = mediaUploadInfos.map { it.toGalleryItemInfo() }
timeline.sendGallery(
items = galleryItems,
@@ -184,10 +188,10 @@ class DefaultMediaSender(
formattedCaption = formattedCaption,
inReplyToEventId = inReplyToEventId,
)
+ .flatMapCatching { uploadHandler ->
+ uploadHandler.await()
+ }
}
- .flatMapCatching { uploadHandler ->
- uploadHandler.await()
- }
.handleSendResult(galleryLogId)
}
@@ -269,12 +273,23 @@ class DefaultMediaSender(
}
}
- private suspend fun getTimeline(): Result {
+ /**
+ * Invokes [block] with the [Timeline] matching [timelineMode].
+ *
+ * For a thread, a dedicated timeline is created and closed once [block] returns, since such a
+ * timeline owns SDK resources. [block] must then perform the whole sending, including waiting
+ * for the media upload to complete. For the other modes, the live timeline is used, and is not
+ * closed here since it is owned by its room.
+ */
+ private suspend fun withTimeline(block: suspend (Timeline) -> Result): Result {
return when (timelineMode) {
is Timeline.Mode.Thread -> {
room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId))
+ .flatMap { threadedTimeline ->
+ threadedTimeline.use { block(it) }
+ }
}
- else -> Result.success(room.liveTimeline)
+ else -> block(room.liveTimeline)
}
}
diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt
index cfa3585629..97a9b95c51 100644
--- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt
+++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@@ -156,14 +157,52 @@ class DefaultMediaSenderTest : RobolectricTest() {
sendFileResult.assertions().isCalledOnce()
}
+ @Test
+ fun `given a thread, the created timeline is closed once the media is sent`() = runTest {
+ val threadedTimeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
+ sendFileLambda = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) }
+ }
+ val liveTimeline = FakeTimeline()
+ val room = FakeJoinedRoom(
+ liveTimeline = liveTimeline,
+ createTimelineResult = { Result.success(threadedTimeline) },
+ )
+ val sender = createDefaultMediaSender(
+ room = room,
+ timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
+ )
+
+ val uri = Uri.parse("content://image.jpg")
+ val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(threadedTimeline.closeCounter).isEqualTo(1)
+ assertThat(liveTimeline.closeCounter).isEqualTo(0)
+ }
+
+ @Test
+ fun `given a live timeline, the timeline is not closed once the media is sent`() = runTest {
+ val liveTimeline = FakeTimeline().apply {
+ sendFileLambda = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) }
+ }
+ val sender = createDefaultMediaSender(room = FakeJoinedRoom(liveTimeline = liveTimeline))
+
+ val uri = Uri.parse("content://image.jpg")
+ val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(liveTimeline.closeCounter).isEqualTo(0)
+ }
+
private fun createDefaultMediaSender(
preProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
room: JoinedRoom = FakeJoinedRoom(),
+ timelineMode: Timeline.Mode = Timeline.Mode.Live,
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig },
) = DefaultMediaSender(
preProcessor = preProcessor,
room = room,
- timelineMode = Timeline.Mode.Live,
+ timelineMode = timelineMode,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt
index 2ffaed7af0..4ad4d76383 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt
@@ -153,6 +153,9 @@ private fun VoiceInfoRow(
onSeek = {
state.eventSink(VoiceMessageEvent.Seek(it))
},
+ isPlaying = state.isPlaying,
+ durationMs = state.durationMs,
+ playbackSpeed = state.playbackSpeed,
seekEnabled = !isValidating,
)
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
index 4ef746cb89..9f68212304 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
@@ -47,6 +47,7 @@ import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
+import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
@@ -124,6 +125,7 @@ private fun ExoPlayerMediaAudioView(
canMute = false,
isMuted = false,
seekingToMillis = null,
+ playbackSpeed = exoPlayer.playbackParameters.speed,
)
)
}
@@ -156,6 +158,12 @@ private fun ExoPlayerMediaAudioView(
)
}
+ override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
+ mediaPlayerControllerState = mediaPlayerControllerState.copy(
+ playbackSpeed = playbackParameters.speed,
+ )
+ }
+
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
@@ -276,6 +284,9 @@ private fun ExoPlayerMediaAudioView(
onSeek = {
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
},
+ isPlaying = mediaPlayerControllerState.isPlaying,
+ durationMs = mediaPlayerControllerState.durationInMillis,
+ playbackSpeed = mediaPlayerControllerState.playbackSpeed,
seekEnabled = true,
)
} else {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
index e23cb790db..6b08fb9d04 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt
@@ -120,7 +120,9 @@ class ExoPlayerForPreview(
override fun seekToNext() {}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
override fun setPlaybackSpeed(speed: Float) {}
- override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError()
+
+ // Cannot throw, this method is invoked when recording Paparazzi screenshots.
+ override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun stop() {}
override fun release() {}
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt
index 79bd7be503..275d001ad9 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt
@@ -19,6 +19,7 @@ data class MediaPlayerControllerState(
val canMute: Boolean,
val isMuted: Boolean,
val seekingToMillis: Long?,
+ val playbackSpeed: Float,
) {
/**
* The progress in milliseconds to display. When [seekingToMillis] is non-null (during a seek operation),
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt
index b229d11283..f60a81d504 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStatePreviewParam.kt
@@ -35,6 +35,7 @@ private fun aMediaPlayerControllerState(
canMute: Boolean = true,
isMuted: Boolean = false,
seekingToMillis: Long? = null,
+ playbackSpeed: Float = 1f,
) = MediaPlayerControllerState(
isVisible = isVisible,
isPlaying = isPlaying,
@@ -44,4 +45,5 @@ private fun aMediaPlayerControllerState(
canMute = canMute,
isMuted = isMuted,
seekingToMillis = seekingToMillis,
+ playbackSpeed = playbackSpeed,
)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
index b5c65fa609..0a06695485 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
@@ -118,6 +118,7 @@ private fun ExoPlayerMediaVideoView(
canMute = true,
isMuted = false,
seekingToMillis = null,
+ playbackSpeed = exoPlayer.playbackParameters.speed,
)
)
}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt
new file mode 100644
index 0000000000..283fa57f9e
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioViewTest.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.
+ */
+
+@file:OptIn(ExperimentalTestApi::class)
+
+package io.element.android.libraries.mediaviewer.impl.local.audio
+
+import android.net.Uri
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
+import com.bumble.appyx.core.node.LocalNodeTargetVisibility
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.libraries.designsystem.components.media.WaveFormSamples
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
+import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
+import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
+import io.element.android.tests.testutils.robolectric.RobolectricTest
+import io.element.android.tests.testutils.setSafeContent
+import org.junit.Test
+
+class MediaAudioViewTest : RobolectricTest() {
+ @Test
+ fun `an audio file without a waveform shows the filename`() = runAndroidComposeUiTest {
+ val info = anAudioMediaInfo(filename = "track.mp3")
+ setMediaAudioView(info = info)
+ onNodeWithText("track.mp3").assertIsDisplayed()
+ }
+
+ @Test
+ fun `an audio file with a waveform hides the filename in favour of the waveform`() = runAndroidComposeUiTest {
+ val info = anAudioMediaInfo(
+ filename = "voice.ogg",
+ waveForm = WaveFormSamples.realisticWaveForm,
+ )
+ setMediaAudioView(info = info)
+ onNodeWithText("voice.ogg").assertDoesNotExist()
+ }
+
+ @Test
+ fun `audio is paused when the view is not displayed`() = runAndroidComposeUiTest {
+ setMediaAudioView(isDisplayed = false)
+ waitForIdle()
+ }
+
+ @Test
+ fun `audio is paused when the host node is not visible`() = runAndroidComposeUiTest {
+ setMediaAudioView(isTargetVisible = false)
+ waitForIdle()
+ }
+
+ @Test
+ fun `a local media uri is prepared on the player`() = runAndroidComposeUiTest {
+ setMediaAudioView(
+ info = anAudioMediaInfo(
+ filename = "voice.ogg",
+ waveForm = WaveFormSamples.realisticWaveForm,
+ ),
+ localMediaUri = Uri.parse("file://voice.ogg"),
+ )
+ waitForIdle()
+ }
+}
+
+private fun AndroidComposeUiTest.setMediaAudioView(
+ info: MediaInfo = anAudioMediaInfo(),
+ isDisplayed: Boolean = true,
+ isTargetVisible: Boolean = true,
+ localMediaUri: Uri? = null,
+) {
+ setSafeContent {
+ CompositionLocalProvider(
+ LocalInspectionMode provides true,
+ LocalNodeTargetVisibility provides isTargetVisible,
+ ) {
+ ElementTheme {
+ MediaAudioView(
+ localMediaViewState = rememberLocalMediaViewState(),
+ bottomPaddingInPixels = 0,
+ localMedia = localMediaUri?.let { aLocalMedia(uri = it, mediaInfo = info) },
+ info = info,
+ audioFocus = null,
+ isDisplayed = isDisplayed,
+ )
+ }
+ }
+ }
+}
diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
index 6285c46389..0a774443a0 100644
--- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
+++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt
@@ -44,6 +44,14 @@ interface AppPreferencesStore {
/** The name of the chosen theme, or `null` when the user has not picked one and the system setting should be followed. */
fun getThemeFlow(): Flow
+ /**
+ * @param expanded true to expand the other accounts section.
+ */
+ suspend fun setOtherAccountsExpanded(expanded: Boolean)
+
+ /** Whether the other accounts section is expanded; defaults to `true`. */
+ fun isOtherAccountsExpandedFlow(): Flow
+
/**
* @param value the distance in metres the user must move before a new live location is published.
*/
diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
index 1a14a0ea8f..66a6eaf61f 100644
--- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
+++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt
@@ -33,6 +33,7 @@ private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
private val themeKey = stringPreferencesKey("theme")
private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars")
+private val otherAccountsExpandedKey = booleanPreferencesKey("otherAccountsExpanded")
private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue")
private val liveLocationMinimumDistanceUpdateKey = intPreferencesKey("liveLocationMinimumDistanceUpdate")
private val logLevelKey = stringPreferencesKey("logLevel")
@@ -97,6 +98,18 @@ class DefaultAppPreferencesStore(
}
}
+ override suspend fun setOtherAccountsExpanded(expanded: Boolean) {
+ store.edit { prefs ->
+ prefs[otherAccountsExpandedKey] = expanded
+ }
+ }
+
+ override fun isOtherAccountsExpandedFlow(): Flow {
+ return store.data.map { prefs ->
+ prefs[otherAccountsExpandedKey] ?: true
+ }
+ }
+
override suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) {
store.edit { prefs ->
prefs[liveLocationMinimumDistanceUpdateKey] = value
diff --git a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt
index 4cf018371a..1d30278bd1 100644
--- a/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt
+++ b/libraries/preferences/impl/src/test/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStoreTest.kt
@@ -120,6 +120,24 @@ class DefaultAppPreferencesStoreTest {
assertThat(history.last()).isEqualTo("https://server5.org")
}
+ @Test
+ fun `other accounts section is expanded by default`() = runTest {
+ val store = createStore()
+
+ assertThat(store.isOtherAccountsExpandedFlow().first()).isTrue()
+ }
+
+ @Test
+ fun `other accounts expanded state persists updates`() = runTest {
+ val store = createStore()
+
+ store.setOtherAccountsExpanded(false)
+ assertThat(store.isOtherAccountsExpandedFlow().first()).isFalse()
+
+ store.setOtherAccountsExpanded(true)
+ assertThat(store.isOtherAccountsExpandedFlow().first()).isTrue()
+ }
+
private fun createStore() = DefaultAppPreferencesStore(
buildMeta = buildMeta,
preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt
index e8d0b0bb8e..458be27ac9 100644
--- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt
+++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.updateAndGet
class InMemoryAppPreferencesStore(
isDeveloperModeEnabled: Boolean = false,
+ isOtherAccountsExpanded: Boolean = true,
customElementCallBaseUrl: String? = null,
hideInviteAvatars: Boolean? = null,
timelineMediaPreviewValue: MediaPreviewValue? = null,
@@ -37,6 +38,7 @@ class InMemoryAppPreferencesStore(
callRingtoneDisplayName: String? = null,
) : AppPreferencesStore {
private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
+ private val isOtherAccountsExpanded = MutableStateFlow(isOtherAccountsExpanded)
private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
private val theme = MutableStateFlow(theme)
private val liveLocationMinimumDistanceUpdate = MutableStateFlow(liveLocationMinimumDistanceUpdate)
@@ -60,6 +62,14 @@ class InMemoryAppPreferencesStore(
return isDeveloperModeEnabled
}
+ override suspend fun setOtherAccountsExpanded(expanded: Boolean) {
+ isOtherAccountsExpanded.value = expanded
+ }
+
+ override fun isOtherAccountsExpandedFlow(): Flow {
+ return isOtherAccountsExpanded
+ }
+
override suspend fun setCustomElementCallBaseUrl(string: String?) {
customElementCallBaseUrl.tryEmit(string)
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt
index 88ba6c5ff9..e1b6687e14 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -31,18 +32,25 @@ import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationSto
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
import io.element.android.libraries.push.impl.push.OnRedactedEventReceived
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.selects.select
import timber.log.Timber
private const val TAG = "NotifResultProcessor"
interface NotificationResultProcessor {
+ /**
+ * Queue a batch of resolved push events for processing and suspend until it has been processed, so that the caller can keep the process
+ * alive (e.g. through a foreground service) until any resulting notification or ringing call has been set up.
+ */
suspend fun emit(results: Map>)
fun start()
fun stop()
@@ -61,23 +69,50 @@ class DefaultNotificationResultProcessor(
private val notificationChannels: NotificationChannels,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : NotificationResultProcessor {
- private val resultFlow = MutableSharedFlow