diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt index df9de58884..3ca1a8217b 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt @@ -4,7 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import io.element.android.x.features.messages.html.HtmlDocument +import io.element.android.x.features.messages.components.html.HtmlDocument import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent @Composable @@ -12,10 +12,11 @@ fun MessagesTimelineItemTextView( content: MessagesTimelineItemTextBasedContent, modifier: Modifier = Modifier ) { - Box(modifier) { - if (content.htmlDocument != null) { - HtmlDocument(document = content.htmlDocument!!) - } else { + val htmlDocument = content.htmlDocument + if (htmlDocument != null) { + HtmlDocument(document = htmlDocument, modifier) + } else { + Box(modifier) { Text(text = content.body) } } diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt new file mode 100644 index 0000000000..8c28c32891 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt @@ -0,0 +1,310 @@ +package io.element.android.x.features.messages.components.html + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode + +@Composable +fun HtmlDocument(document: Document, modifier: Modifier = Modifier) { + HtmlBody(body = document.body(), modifier = modifier) +} + +@Composable +private fun HtmlBody(body: Element, modifier: Modifier = Modifier) { + Column( + modifier = modifier + ) { + for (node in body.childNodes()) { + when (node) { + is TextNode -> { + if (!node.isBlank) { + Text(text = node.text()) + } + } + is Element -> { + HtmlBlock(element = node) + } + else -> { + continue + } + } + } + } +} + +@Composable +private fun HtmlBlock(element: Element, modifier: Modifier = Modifier) { + val blockModifier = modifier + .padding(top = 4.dp) + when (element.normalName()) { + "p" -> HtmlParagraph(element, blockModifier) + "h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(element, blockModifier) + "ol" -> HtmlOrderedList(element, blockModifier) + "ul" -> HtmlUnorderedList(element, blockModifier) + "blockquote" -> HtmlBlockquote(element, blockModifier) + "pre" -> HtmlPreformatted(element, blockModifier) + "mx-reply" -> HtmlMxReply(element, blockModifier) + // fallback to html inline + else -> HtmlInline(element, modifier) + } +} + +@Composable +private fun HtmlInline(element: Element, modifier: Modifier = Modifier) { + Box(modifier.padding(start = 8.dp)) { + val styledText = buildAnnotatedString { + appendInlineElement(element, MaterialTheme.colorScheme) + } + Text(styledText) + } +} + +@Composable +private fun HtmlPreformatted(pre: Element, modifier: Modifier = Modifier) { + val isCode = pre.firstElementChild()?.normalName() == "code" + val backgroundColor = + if (isCode) MaterialTheme.colorScheme.codeBackground() else Color.Unspecified + Box(modifier.background(color = backgroundColor)) { + Text( + text = pre.wholeText(), + style = TextStyle(fontFamily = FontFamily.Monospace), + ) + } +} + +@Composable +private fun HtmlParagraph(paragraph: Element, modifier: Modifier = Modifier) { + Box(modifier) { + val styledText = buildAnnotatedString { + appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme) + } + Text(styledText) + } +} + +@Composable +private fun HtmlBlockquote(blockquote: Element, modifier: Modifier = Modifier) { + val color = MaterialTheme.colorScheme.onBackground + Box( + modifier = modifier + .drawBehind { + drawLine( + color = color, + strokeWidth = 2f, + start = Offset(12.dp.value, 0f), + end = Offset(12.dp.value, size.height) + ) + } + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp) + ) { + val text = buildAnnotatedString { + withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme) + } + } + Text(text) + } +} + + +@Composable +private fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) { + val style = when (heading.normalName()) { + "h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp) + "h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp) + "h3" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 22.sp) + "h4" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 18.sp) + "h5" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 14.sp) + "h6" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 12.sp) + else -> { + return + } + } + Box(modifier) { + val text = buildAnnotatedString { + appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme) + } + Text(text, style = style) + } +} + +@Composable +private fun HtmlMxReply(mxReply: Element, modifier: Modifier = Modifier) { + val blockquote = mxReply.childNodes().firstOrNull() ?: return + val shape = RoundedCornerShape(12.dp) + Surface( + modifier = modifier.offset(x = -(8.dp)), + color = MaterialTheme.colorScheme.background, + shape = shape, + ) { + val text = buildAnnotatedString { + for (blockquoteNode in blockquote.childNodes()) { + when (blockquoteNode) { + is TextNode -> { + withStyle( + style = SpanStyle( + fontSize = 12.sp, + color = MaterialTheme.colorScheme.secondary + ) + ) { + append(blockquoteNode.text()) + } + } + is Element -> { + when (blockquoteNode.normalName()) { + "br" -> { + append('\n') + } + "a" -> { + append(blockquoteNode.ownText()) + } + } + } + } + } + } + Text(text, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) + } +} + +@Composable +private fun HtmlOrderedList(unorderedList: Element, modifier: Modifier = Modifier) { + var number = 1 + val delimiter = "." + HtmlListItems(unorderedList, modifier = modifier) { + val text = buildAnnotatedString { + append("${number++}$delimiter ${it.text()}") + } + Text(text) + } +} + +@Composable +private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modifier) { + val marker = "・" + HtmlListItems(unorderedList, modifier = modifier) { + val text = buildAnnotatedString { + append("$marker ${it.text()}") + } + Text(text) + } +} + + +@Composable +private fun HtmlListItems( + list: Element, + modifier: Modifier = Modifier, + content: @Composable (node: TextNode) -> Unit +) { + Column(modifier = modifier) { + for (node in list.children()) { + for (innerNode in node.childNodes()) { + when (innerNode) { + is TextNode -> { + if (!innerNode.isBlank) content(innerNode) + } + is Element -> HtmlBlock( + element = innerNode, + modifier = modifier.padding(start = 4.dp) + ) + } + } + + } + } +} + +private fun ColorScheme.codeBackground(): Color { + return background.copy(alpha = 0.3f) +} + +private fun AnnotatedString.Builder.appendInlineChildrenElements( + childNodes: List, + colors: ColorScheme +) { + + for (node in childNodes) { + when (node) { + is TextNode -> { + append(node.text()) + } + is Element -> { + appendInlineElement(node, colors) + } + } + } +} + + +private fun AnnotatedString.Builder.appendInlineElement(element: Element, colors: ColorScheme) { + when (element.normalName()) { + "br" -> { + append('\n') + } + "code" -> { + withStyle( + style = TextStyle( + fontFamily = FontFamily.Monospace, + background = colors.codeBackground() + ).toSpanStyle() + ) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "del" -> { + withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "em" -> { + withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "strong" -> { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "a" -> { + val href = element.attr("href") + pushStringAnnotation(tag = "url", annotation = href) + withStyle( + style = SpanStyle( + color = Color.Blue, + textDecoration = TextDecoration.Underline + ) + ) { + append(element.ownText()) + } + pop() + } + else -> { + appendInlineChildrenElements(element.childNodes(), colors) + } + } +} diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/html/HtmlDocument.kt b/features/messages/src/main/java/io/element/android/x/features/messages/html/HtmlDocument.kt deleted file mode 100644 index 1c95b26948..0000000000 --- a/features/messages/src/main/java/io/element/android/x/features/messages/html/HtmlDocument.kt +++ /dev/null @@ -1,269 +0,0 @@ -package io.element.android.x.features.messages.html - -import androidx.compose.foundation.Image -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import coil.compose.rememberImagePainter -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import org.jsoup.nodes.Node -import org.jsoup.nodes.TextNode - -@Composable -fun HtmlDocument(document: Document, modifier: Modifier = Modifier) { - HtmlBody(body = document.body(), modifier = modifier) -} - -@Composable -fun HtmlBody(body: Element, modifier: Modifier = Modifier) { - Column( - modifier = modifier - ) { - for (node in body.childNodes()) { - when (node) { - is TextNode -> { - if (!node.isBlank) { - Text( - text = node.text(), - style = MaterialTheme.typography.body1 - ) - } - } - is Element -> { - HtmlBlock(node) - } - else -> { - return - } - } - } - } -} - -@Composable -fun HtmlBlock(element: Element, modifier: Modifier = Modifier) { - val blockModifier = modifier - .fillMaxWidth() - .padding(top = 4.dp) - when (element.normalName()) { - "p" -> HtmlParagraph(element, blockModifier) - "h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(heading = element, blockModifier) - "ol" -> HtmlOrderedList(element, blockModifier) - "ul" -> HtmlUnorderedList(element, blockModifier) - "blockquote" -> Column { - for (e in element.children()) { - HtmlBlock(element = e) - } - } - } -} - - -@Composable -fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) { - val style = when (heading.normalName()) { - "h1" -> MaterialTheme.typography.h1 - "h2" -> MaterialTheme.typography.h2 - "h3" -> MaterialTheme.typography.h3 - "h4" -> MaterialTheme.typography.h4 - "h5" -> MaterialTheme.typography.h5 - "h6" -> MaterialTheme.typography.h6 - else -> { - return - } - } - Box(modifier) { - val text = buildAnnotatedString { - appendInlineChildrenElements(heading.childNodes()) - } - HtmlText(text, style) - } -} - - -@Composable -private fun HtmlOrderedList(unorderedList: Element, modifier: Modifier = Modifier) { - var number = 0 - val delimiter = "." - HtmlListItems(unorderedList, modifier = modifier) { - val text = buildAnnotatedString { - pushStyle(MaterialTheme.typography.body1.toSpanStyle()) - append("${number++}$delimiter ") - appendInlineElements(it) - pop() - } - HtmlText(text, MaterialTheme.typography.body1, modifier) - } -} - -@Composable -private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modifier) { - val marker = "-" - HtmlListItems(unorderedList, modifier = modifier) { - val text = buildAnnotatedString { - pushStyle(MaterialTheme.typography.body1.toSpanStyle()) - append("$marker ") - appendInlineElements(it) - pop() - } - HtmlText(text, MaterialTheme.typography.body1, modifier) - } -} - - -@Composable -fun HtmlListItems( - list: Element, - modifier: Modifier = Modifier, - content: @Composable (node: Element) -> Unit -) { - if (list.children().isEmpty()) return - Column(modifier = modifier) { - val children = list.children().iterator() - var listItem = children.next() - while (listItem != null) { - val innerChildren = listItem.children().iterator() - var child = if (innerChildren.hasNext()) { - innerChildren.next() - } else { - null - } - while (child != null) { - when (child.normalName()) { - "ul" -> HtmlUnorderedList(child, modifier) - "ol" -> HtmlOrderedList(child, modifier) - else -> content(child) - } - child = if (innerChildren.hasNext()) { - innerChildren.next() - } else { - null - } - } - listItem = if (children.hasNext()) { - children.next() - } else { - null - } - } - } -} - -private fun AnnotatedString.Builder.appendInlineChildrenElements(childNodes: List) { - for (node in childNodes) { - when (node) { - is TextNode -> { - append(node.text()) - } - is Element -> { - appendInlineElements(node) - } - } - } -} - -private fun AnnotatedString.Builder.appendInlineElements(element: Element) { - when (element.normalName()) { - "br" -> { - append('\n') - } - "del" -> { - withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) { - appendInlineChildrenElements(element.childNodes()) - } - } - "em" -> { - withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) { - appendInlineChildrenElements(element.childNodes()) - } - } - "strong" -> { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - appendInlineChildrenElements(element.childNodes()) - } - } - "a" -> { - val href = element.attr("href") - pushStringAnnotation(tag = "url", annotation = href) - withStyle( - style = SpanStyle( - color = Color.Blue, - textDecoration = TextDecoration.Underline - ) - ) { - append(element.ownText()) - } - pop() - } - } -} - -@Composable -fun HtmlText(text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier) { - val uriHandler = LocalUriHandler.current - val layoutResult = remember { mutableStateOf(null) } - Text(text = text, - modifier.pointerInput(Unit) { - detectTapGestures { offset -> - layoutResult.value?.let { layoutResult -> - val position = layoutResult.getOffsetForPosition(offset) - text.getStringAnnotations(position, position) - .firstOrNull() - ?.let { sa -> - if (sa.tag == "url") { - uriHandler.openUri(sa.item) - } - } - } - } - }, - style = style, - inlineContent = mapOf( - "imageUrl" to InlineTextContent( - Placeholder(style.fontSize, style.fontSize, PlaceholderVerticalAlign.Bottom) - ) { - Image( - painter = rememberImagePainter( - data = it, - ), - contentDescription = null, - modifier = modifier, - alignment = Alignment.Center - ) - - } - ), - onTextLayout = { layoutResult.value = it } - ) -} - -@Composable -private fun HtmlParagraph(paragraph: Element, modifier: Modifier = Modifier) { - Box(modifier) { - val styledText = buildAnnotatedString { - pushStyle(MaterialTheme.typography.body1.toSpanStyle()) - appendInlineChildrenElements(paragraph.childNodes()) - pop() - } - HtmlText(styledText, MaterialTheme.typography.body1) - } -} \ No newline at end of file diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt index bf8fd844b0..57c6edd330 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt @@ -11,7 +11,7 @@ class MessagesTimelineItemContentProvider : PreviewParameterProvider