Html rendering: fix lots of things and add clickable links
This commit is contained in:
parent
45cf334d6e
commit
1655fe80d6
8 changed files with 710 additions and 67 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue