Html rendering: fix lots of things and add clickable links

This commit is contained in:
ganfra 2022-12-01 20:41:19 +01:00
parent 45cf334d6e
commit 1655fe80d6
8 changed files with 710 additions and 67 deletions

View file

@ -3,6 +3,7 @@
package io.element.android.x.features.messages
import Avatar
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@ -334,6 +335,7 @@ fun MessageEventRow(
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
Pair(Alignment.CenterEnd, End)
} else {
@ -360,6 +362,7 @@ fun MessageEventRow(
MessageEventBubble(
groupPosition = messageEvent.groupPosition,
isMine = messageEvent.isMine,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
@ -378,7 +381,9 @@ fun MessageEventRow(
)
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
content = messageEvent.content,
modifier = contentModifier
interactionSource = interactionSource,
modifier = contentModifier,
onTextClicked = onClick
)
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
content = messageEvent.content,

View file

@ -24,6 +24,7 @@ private val BUBBLE_RADIUS = 16.dp
fun MessageEventBubble(
groupPosition: MessagesItemGroupPosition,
isMine: Boolean,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
@ -87,7 +88,7 @@ fun MessageEventBubble(
onClick = onClick,
onLongClick = onLongClick,
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() }
interactionSource = interactionSource
),
color = backgroundBubbleColor,
shape = bubbleShape,

View file

@ -1,5 +1,6 @@
package io.element.android.x.features.messages.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -10,11 +11,18 @@ import io.element.android.x.features.messages.model.content.MessagesTimelineItem
@Composable
fun MessagesTimelineItemTextView(
content: MessagesTimelineItemTextBasedContent,
modifier: Modifier = Modifier
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
) {
val htmlDocument = content.htmlDocument
if (htmlDocument != null) {
HtmlDocument(document = htmlDocument, modifier)
HtmlDocument(
document = htmlDocument,
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
} else {
Box(modifier) {
Text(text = content.body)

View file

@ -1,20 +1,27 @@
@file:OptIn(ExperimentalMaterialApi::class)
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.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.*
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.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
@ -22,63 +29,161 @@ 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 com.google.accompanist.flowlayout.FlowRow
import io.element.android.x.matrix.permalink.PermalinkData
import io.element.android.x.matrix.permalink.PermalinkParser
import kotlinx.coroutines.launch
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
private const val chipId = "chip"
@Composable
fun HtmlDocument(document: Document, modifier: Modifier = Modifier) {
HtmlBody(body = document.body(), modifier = modifier)
fun HtmlDocument(
document: Document,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
HtmlBody(
body = document.body(),
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
}
@Composable
private fun HtmlBody(body: Element, modifier: Modifier = Modifier) {
Column(
modifier = modifier
private fun HtmlBody(
body: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
@Composable
fun NodesFlowRode(
nodes: Iterator<Node>,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) = FlowRow(
mainAxisSpacing = 2.dp,
crossAxisSpacing = 8.dp,
) {
for (node in body.childNodes()) {
when (node) {
var sameRow = true
while (sameRow && nodes.hasNext()) {
when (val node = nodes.next()) {
is TextNode -> {
if (!node.isBlank) {
Text(text = node.text())
}
}
is Element -> {
HtmlBlock(element = node)
}
else -> {
continue
if (node.isInline()) {
HtmlInline(
node,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
} else {
HtmlBlock(
element = node,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
sameRow = false
}
}
else -> continue
}
}
}
Column(modifier = modifier) {
val nodesIterator = body.childNodes().iterator()
while (nodesIterator.hasNext()) {
NodesFlowRode(
nodes = nodesIterator,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
}
}
}
@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)
private fun Element.isInline(): Boolean {
return when (normalName()) {
"del" -> true
"mx-reply" -> false
else -> !isBlock
}
}
@Composable
private fun HtmlInline(element: Element, modifier: Modifier = Modifier) {
Box(modifier.padding(start = 8.dp)) {
private fun HtmlBlock(
element: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val blockModifier = modifier
.padding(top = 4.dp)
when (element.normalName()) {
"p" -> HtmlParagraph(
paragraph = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(
heading = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"ol" -> HtmlOrderedList(
orderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"ul" -> HtmlUnorderedList(
unorderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"blockquote" -> HtmlBlockquote(
blockquote = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"pre" -> HtmlPreformatted(element, blockModifier)
"mx-reply" -> HtmlMxReply(
mxReply = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
else -> return
}
}
@Composable
private fun HtmlInline(
element: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
Box(modifier) {
val styledText = buildAnnotatedString {
appendInlineElement(element, MaterialTheme.colorScheme)
}
Text(styledText)
HtmlText(text = styledText, onClick = onTextClicked, interactionSource = interactionSource)
}
}
@ -100,17 +205,27 @@ private fun HtmlPreformatted(pre: Element, modifier: Modifier = Modifier) {
}
@Composable
private fun HtmlParagraph(paragraph: Element, modifier: Modifier = Modifier) {
private fun HtmlParagraph(
paragraph: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
Box(modifier) {
val styledText = buildAnnotatedString {
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
}
Text(styledText)
HtmlText(text = styledText, onClick = onTextClicked, interactionSource = interactionSource)
}
}
@Composable
private fun HtmlBlockquote(blockquote: Element, modifier: Modifier = Modifier) {
private fun HtmlBlockquote(
blockquote: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val color = MaterialTheme.colorScheme.onBackground
Box(
modifier = modifier
@ -129,13 +244,18 @@ private fun HtmlBlockquote(blockquote: Element, modifier: Modifier = Modifier) {
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
}
}
Text(text)
HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
}
}
@Composable
private fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) {
private fun HtmlHeading(
heading: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val style = when (heading.normalName()) {
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
@ -151,16 +271,28 @@ private fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) {
val text = buildAnnotatedString {
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
}
Text(text, style = style)
HtmlText(
text = text,
style = style,
onClick = onTextClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlMxReply(mxReply: Element, modifier: Modifier = Modifier) {
private fun HtmlMxReply(
mxReply: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val blockquote = mxReply.childNodes().firstOrNull() ?: return
val shape = RoundedCornerShape(12.dp)
Surface(
modifier = modifier.offset(x = -(8.dp)),
modifier = modifier
.padding(bottom = 4.dp)
.offset(x = -(8.dp)),
color = MaterialTheme.colorScheme.background,
shape = shape,
) {
@ -190,30 +322,55 @@ private fun HtmlMxReply(mxReply: Element, modifier: Modifier = Modifier) {
}
}
}
Text(text, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
HtmlText(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
onClick = onTextClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlOrderedList(unorderedList: Element, modifier: Modifier = Modifier) {
private fun HtmlOrderedList(
orderedList: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
var number = 1
val delimiter = "."
HtmlListItems(unorderedList, modifier = modifier) {
HtmlListItems(
list = orderedList,
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
) {
val text = buildAnnotatedString {
append("${number++}$delimiter ${it.text()}")
}
Text(text)
HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
}
}
@Composable
private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modifier) {
private fun HtmlUnorderedList(
unorderedList: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val marker = ""
HtmlListItems(unorderedList, modifier = modifier) {
HtmlListItems(
list = unorderedList,
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
) {
val text = buildAnnotatedString {
append("$marker ${it.text()}")
}
Text(text)
HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
}
}
@ -222,6 +379,8 @@ private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modif
private fun HtmlListItems(
list: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
content: @Composable (node: TextNode) -> Unit
) {
Column(modifier = modifier) {
@ -233,7 +392,9 @@ private fun HtmlListItems(
}
is Element -> HtmlBlock(
element = innerNode,
modifier = modifier.padding(start = 4.dp)
modifier = modifier.padding(start = 4.dp),
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
}
}
@ -295,20 +456,79 @@ private fun AnnotatedString.Builder.appendInlineElement(element: Element, 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()
appendLink(element)
}
else -> {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
}
private fun AnnotatedString.Builder.appendLink(link: Element) {
val uriString = link.attr("href")
val permalinkData = PermalinkParser.parse(uriString)
when (permalinkData) {
is PermalinkData.FallbackLink -> {
pushStringAnnotation(tag = "link", annotation = link.ownText())
withStyle(
style = SpanStyle(color = Color.Blue)
) {
append(link.ownText())
}
pop()
}
is PermalinkData.RoomEmailInviteLink -> {
appendInlineContent(chipId, link.ownText())
}
is PermalinkData.RoomLink -> {
appendInlineContent(chipId, link.ownText())
}
is PermalinkData.UserLink -> {
appendInlineContent(chipId, link.ownText())
}
}
}
@Composable
private fun HtmlText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
onClick: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val coroutineScope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures { offset ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(offset)
val linkAnnotations = text.getStringAnnotations("link", position, position)
if (linkAnnotations.isEmpty()) {
onClick()
coroutineScope.launch {
val pressInteraction = PressInteraction.Press(offset)
interactionSource.emit(pressInteraction)
interactionSource.emit(PressInteraction.Release(pressInteraction))
}
} else {
uriHandler.openUri(linkAnnotations.first().item)
}
}
}
}
val inlineContentMap = emptyMap<String, InlineTextContent>()
Text(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
onTextLayout = {
layoutResult.value = it
},
inlineContent = inlineContentMap
)
}