Browse Source

Continue working on html rendering

feature/bma/flipper
ganfra 2 years ago
parent
commit
659cfba4d4
  1. 11
      features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt
  2. 310
      features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt
  3. 269
      features/messages/src/main/java/io/element/android/x/features/messages/html/HtmlDocument.kt
  4. 6
      features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt

11
features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt

@ -4,7 +4,7 @@ import androidx.compose.foundation.layout.Box @@ -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( @@ -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)
}
}

310
features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt

@ -0,0 +1,310 @@ @@ -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<Node>,
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)
}
}
}

269
features/messages/src/main/java/io/element/android/x/features/messages/html/HtmlDocument.kt

@ -1,269 +0,0 @@ @@ -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<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)
}
}

6
features/messages/src/main/java/io/element/android/x/features/messages/model/content/MessagesTimelineItemContent.kt

@ -11,7 +11,7 @@ class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTim @@ -11,7 +11,7 @@ class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTim
override val values = sequenceOf(
MessagesTimelineItemEmoteContent(
body = "Emote",
formattedBody = FormattedBody(MessageFormat.HTML, "Formatted emote")
htmlDocument = null
),
MessagesTimelineItemEncryptedContent(
encryptedMessage = EncryptedMessage.Unknown
@ -19,12 +19,12 @@ class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTim @@ -19,12 +19,12 @@ class MessagesTimelineItemContentProvider : PreviewParameterProvider<MessagesTim
// TODO MessagesTimelineItemImageContent(),
MessagesTimelineItemNoticeContent(
body = "Notice",
formattedBody = FormattedBody(MessageFormat.HTML, "Formatted notice")
htmlDocument = null
),
MessagesTimelineItemRedactedContent,
MessagesTimelineItemTextContent(
body = "Text",
formattedBody = FormattedBody(MessageFormat.HTML, "Formatted text")
htmlDocument = null
),
MessagesTimelineItemUnknownContent,
)

Loading…
Cancel
Save