First tries with Html rendering...
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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<MatrixTimelineItem>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user