From f26404154d0a61573a2c88e163616efcf69a568a Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 29 Nov 2022 21:28:13 +0100 Subject: [PATCH] First tries with Html rendering... --- features/messages/build.gradle.kts | 1 + .../MessageTimelineItemStateMapper.kt | 16 +- .../MessagesTimelineItemTextView.kt | 7 +- .../x/features/messages/html/HtmlDocument.kt | 269 ++++++++++++++++++ .../MessagesTimelineItemEmoteContent.kt | 4 +- .../MessagesTimelineItemNoticeContent.kt | 4 +- .../MessagesTimelineItemTextBasedContent.kt | 4 +- .../MessagesTimelineItemTextContent.kt | 4 +- 8 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 features/messages/src/main/java/io/element/android/x/features/messages/html/HtmlDocument.kt diff --git a/features/messages/build.gradle.kts b/features/messages/build.gradle.kts index 70e3455d1f..2b333cd41e 100644 --- a/features/messages/build.gradle.kts +++ b/features/messages/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.timber) implementation(libs.datetime) implementation(libs.accompanist.flowlayout) + implementation("org.jsoup:jsoup:1.15.3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateMapper.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateMapper.kt index 01a0674c27..cebf95d4ba 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateMapper.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessageTimelineItemStateMapper.kt @@ -13,6 +13,10 @@ import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.timeline.MatrixTimelineItem import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.matrix.rustcomponents.sdk.FormattedBody +import org.matrix.rustcomponents.sdk.MessageFormat import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.TimelineKey @@ -88,7 +92,7 @@ class MessageTimelineItemStateMapper( return when (val messageType = contentAsMessage?.msgtype()) { is MessageType.Emote -> MessagesTimelineItemEmoteContent( body = messageType.content.body, - formattedBody = messageType.content.formatted + htmlDocument = messageType.content.formatted?.toHtmlDocument() ) is MessageType.Image -> { val height = messageType.content.info?.height?.toFloat() @@ -110,17 +114,23 @@ class MessageTimelineItemStateMapper( } is MessageType.Notice -> MessagesTimelineItemNoticeContent( body = messageType.content.body, - formattedBody = messageType.content.formatted + htmlDocument = messageType.content.formatted?.toHtmlDocument() ) is MessageType.Text -> MessagesTimelineItemTextContent( body = messageType.content.body, - formattedBody = messageType.content.formatted + htmlDocument = messageType.content.formatted?.toHtmlDocument() ) else -> MessagesTimelineItemUnknownContent } } + private fun FormattedBody.toHtmlDocument(): Document? { + return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody -> + Jsoup.parse(formattedBody) + } + } + private fun computeGroupPosition( currentTimelineItem: MatrixTimelineItem.Event, timelineItems: List, 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 00b4bc7ff7..df9de58884 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,6 +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.model.content.MessagesTimelineItemTextBasedContent @Composable @@ -12,6 +13,10 @@ fun MessagesTimelineItemTextView( modifier: Modifier = Modifier ) { Box(modifier) { - Text(text = content.body) + if (content.htmlDocument != null) { + HtmlDocument(document = content.htmlDocument!!) + } else { + Text(text = content.body) + } } } \ No newline at end of file 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 new file mode 100644 index 0000000000..1c95b26948 --- /dev/null +++ b/features/messages/src/main/java/io/element/android/x/features/messages/html/HtmlDocument.kt @@ -0,0 +1,269 @@ +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/MessagesTimelineItemEmoteContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEmoteContent.kt index b2f44c50e8..fa3dced3c4 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEmoteContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemEmoteContent.kt @@ -1,8 +1,8 @@ package io.element.android.x.features.messages.model.content -import org.matrix.rustcomponents.sdk.FormattedBody +import org.jsoup.nodes.Document data class MessagesTimelineItemEmoteContent( override val body: String, - override val formattedBody: FormattedBody?, + override val htmlDocument: Document? ) : MessagesTimelineItemTextBasedContent \ No newline at end of file diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemNoticeContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemNoticeContent.kt index 614f6a2616..3f66a64a9c 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemNoticeContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemNoticeContent.kt @@ -1,8 +1,8 @@ package io.element.android.x.features.messages.model.content -import org.matrix.rustcomponents.sdk.FormattedBody +import org.jsoup.nodes.Document data class MessagesTimelineItemNoticeContent( override val body: String, - override val formattedBody: FormattedBody?, + override val htmlDocument: Document? ) : MessagesTimelineItemTextBasedContent \ No newline at end of file diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextBasedContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextBasedContent.kt index 201ccf5d31..4f8c1d67fe 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextBasedContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextBasedContent.kt @@ -1,8 +1,8 @@ package io.element.android.x.features.messages.model.content -import org.matrix.rustcomponents.sdk.FormattedBody +import org.jsoup.nodes.Document sealed interface MessagesTimelineItemTextBasedContent : MessagesTimelineItemContent { val body: String - val formattedBody: FormattedBody? + val htmlDocument: Document? } \ No newline at end of file diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextContent.kt b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextContent.kt index 0ddf0a919b..6f132eebfe 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextContent.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemTextContent.kt @@ -1,8 +1,8 @@ package io.element.android.x.features.messages.model.content -import org.matrix.rustcomponents.sdk.FormattedBody +import org.jsoup.nodes.Document data class MessagesTimelineItemTextContent( override val body: String, - override val formattedBody: FormattedBody?, + override val htmlDocument: Document? ) : MessagesTimelineItemTextBasedContent \ No newline at end of file