First tries with Html rendering...

This commit is contained in:
ganfra 2022-11-29 21:28:13 +01:00
parent b399aebdf5
commit f26404154d
8 changed files with 297 additions and 12 deletions

View file

@ -16,6 +16,7 @@ dependencies {
implementation(libs.timber) implementation(libs.timber)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation("org.jsoup:jsoup:1.15.3")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")

View file

@ -13,6 +13,10 @@ import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.timeline.MatrixTimelineItem import io.element.android.x.matrix.timeline.MatrixTimelineItem
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext 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.MessageType
import org.matrix.rustcomponents.sdk.TimelineKey import org.matrix.rustcomponents.sdk.TimelineKey
@ -88,7 +92,7 @@ class MessageTimelineItemStateMapper(
return when (val messageType = contentAsMessage?.msgtype()) { return when (val messageType = contentAsMessage?.msgtype()) {
is MessageType.Emote -> MessagesTimelineItemEmoteContent( is MessageType.Emote -> MessagesTimelineItemEmoteContent(
body = messageType.content.body, body = messageType.content.body,
formattedBody = messageType.content.formatted htmlDocument = messageType.content.formatted?.toHtmlDocument()
) )
is MessageType.Image -> { is MessageType.Image -> {
val height = messageType.content.info?.height?.toFloat() val height = messageType.content.info?.height?.toFloat()
@ -110,17 +114,23 @@ class MessageTimelineItemStateMapper(
} }
is MessageType.Notice -> MessagesTimelineItemNoticeContent( is MessageType.Notice -> MessagesTimelineItemNoticeContent(
body = messageType.content.body, body = messageType.content.body,
formattedBody = messageType.content.formatted htmlDocument = messageType.content.formatted?.toHtmlDocument()
) )
is MessageType.Text -> MessagesTimelineItemTextContent( is MessageType.Text -> MessagesTimelineItemTextContent(
body = messageType.content.body, body = messageType.content.body,
formattedBody = messageType.content.formatted htmlDocument = messageType.content.formatted?.toHtmlDocument()
) )
else -> MessagesTimelineItemUnknownContent else -> MessagesTimelineItemUnknownContent
} }
} }
private fun FormattedBody.toHtmlDocument(): Document? {
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
Jsoup.parse(formattedBody)
}
}
private fun computeGroupPosition( private fun computeGroupPosition(
currentTimelineItem: MatrixTimelineItem.Event, currentTimelineItem: MatrixTimelineItem.Event,
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.x.features.messages.html.HtmlDocument
import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent import io.element.android.x.features.messages.model.content.MessagesTimelineItemTextBasedContent
@Composable @Composable
@ -12,6 +13,10 @@ fun MessagesTimelineItemTextView(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box(modifier) { Box(modifier) {
Text(text = content.body) if (content.htmlDocument != null) {
HtmlDocument(document = content.htmlDocument!!)
} else {
Text(text = content.body)
}
} }
} }

View file

@ -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<Node>) {
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<TextLayoutResult?>(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)
}
}

View file

@ -1,8 +1,8 @@
package io.element.android.x.features.messages.model.content package io.element.android.x.features.messages.model.content
import org.matrix.rustcomponents.sdk.FormattedBody import org.jsoup.nodes.Document
data class MessagesTimelineItemEmoteContent( data class MessagesTimelineItemEmoteContent(
override val body: String, override val body: String,
override val formattedBody: FormattedBody?, override val htmlDocument: Document?
) : MessagesTimelineItemTextBasedContent ) : MessagesTimelineItemTextBasedContent

View file

@ -1,8 +1,8 @@
package io.element.android.x.features.messages.model.content package io.element.android.x.features.messages.model.content
import org.matrix.rustcomponents.sdk.FormattedBody import org.jsoup.nodes.Document
data class MessagesTimelineItemNoticeContent( data class MessagesTimelineItemNoticeContent(
override val body: String, override val body: String,
override val formattedBody: FormattedBody?, override val htmlDocument: Document?
) : MessagesTimelineItemTextBasedContent ) : MessagesTimelineItemTextBasedContent

View file

@ -1,8 +1,8 @@
package io.element.android.x.features.messages.model.content package io.element.android.x.features.messages.model.content
import org.matrix.rustcomponents.sdk.FormattedBody import org.jsoup.nodes.Document
sealed interface MessagesTimelineItemTextBasedContent : MessagesTimelineItemContent { sealed interface MessagesTimelineItemTextBasedContent : MessagesTimelineItemContent {
val body: String val body: String
val formattedBody: FormattedBody? val htmlDocument: Document?
} }

View file

@ -1,8 +1,8 @@
package io.element.android.x.features.messages.model.content package io.element.android.x.features.messages.model.content
import org.matrix.rustcomponents.sdk.FormattedBody import org.jsoup.nodes.Document
data class MessagesTimelineItemTextContent( data class MessagesTimelineItemTextContent(
override val body: String, override val body: String,
override val formattedBody: FormattedBody?, override val htmlDocument: Document?
) : MessagesTimelineItemTextBasedContent ) : MessagesTimelineItemTextBasedContent