Html rendering: fix lots of things and add clickable links
This commit is contained in:
parent
d2ab0872ec
commit
e7697a7585
8 changed files with 710 additions and 67 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
package io.element.android.x.features.messages
|
package io.element.android.x.features.messages
|
||||||
|
|
||||||
import Avatar
|
import Avatar
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
|
@ -334,6 +335,7 @@ fun MessageEventRow(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
|
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
|
||||||
Pair(Alignment.CenterEnd, End)
|
Pair(Alignment.CenterEnd, End)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -360,6 +362,7 @@ fun MessageEventRow(
|
||||||
MessageEventBubble(
|
MessageEventBubble(
|
||||||
groupPosition = messageEvent.groupPosition,
|
groupPosition = messageEvent.groupPosition,
|
||||||
isMine = messageEvent.isMine,
|
isMine = messageEvent.isMine,
|
||||||
|
interactionSource = interactionSource,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -378,7 +381,9 @@ fun MessageEventRow(
|
||||||
)
|
)
|
||||||
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
|
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
|
||||||
content = messageEvent.content,
|
content = messageEvent.content,
|
||||||
modifier = contentModifier
|
interactionSource = interactionSource,
|
||||||
|
modifier = contentModifier,
|
||||||
|
onTextClicked = onClick
|
||||||
)
|
)
|
||||||
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
|
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
|
||||||
content = messageEvent.content,
|
content = messageEvent.content,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ private val BUBBLE_RADIUS = 16.dp
|
||||||
fun MessageEventBubble(
|
fun MessageEventBubble(
|
||||||
groupPosition: MessagesItemGroupPosition,
|
groupPosition: MessagesItemGroupPosition,
|
||||||
isMine: Boolean,
|
isMine: Boolean,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
|
@ -87,7 +88,7 @@ fun MessageEventBubble(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
indication = rememberRipple(),
|
indication = rememberRipple(),
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = interactionSource
|
||||||
),
|
),
|
||||||
color = backgroundBubbleColor,
|
color = backgroundBubbleColor,
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package io.element.android.x.features.messages.components
|
package io.element.android.x.features.messages.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
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
|
||||||
|
|
@ -10,11 +11,18 @@ import io.element.android.x.features.messages.model.content.MessagesTimelineItem
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesTimelineItemTextView(
|
fun MessagesTimelineItemTextView(
|
||||||
content: MessagesTimelineItemTextBasedContent,
|
content: MessagesTimelineItemTextBasedContent,
|
||||||
modifier: Modifier = Modifier
|
interactionSource: MutableInteractionSource,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onTextClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val htmlDocument = content.htmlDocument
|
val htmlDocument = content.htmlDocument
|
||||||
if (htmlDocument != null) {
|
if (htmlDocument != null) {
|
||||||
HtmlDocument(document = htmlDocument, modifier)
|
HtmlDocument(
|
||||||
|
document = htmlDocument,
|
||||||
|
modifier = modifier,
|
||||||
|
onTextClicked = onTextClicked,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
Text(text = content.body)
|
Text(text = content.body)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,27 @@
|
||||||
|
@file:OptIn(ExperimentalMaterialApi::class)
|
||||||
|
|
||||||
package io.element.android.x.features.messages.components.html
|
package io.element.android.x.features.messages.components.html
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.interaction.PressInteraction
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
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.*
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
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.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.jsoup.nodes.Node
|
import org.jsoup.nodes.Node
|
||||||
import org.jsoup.nodes.TextNode
|
import org.jsoup.nodes.TextNode
|
||||||
|
|
||||||
|
private const val chipId = "chip"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HtmlDocument(document: Document, modifier: Modifier = Modifier) {
|
fun HtmlDocument(
|
||||||
HtmlBody(body = document.body(), modifier = modifier)
|
document: Document,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
|
onTextClicked: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
HtmlBody(
|
||||||
|
body = document.body(),
|
||||||
|
modifier = modifier,
|
||||||
|
onTextClicked = onTextClicked,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HtmlBody(body: Element, modifier: Modifier = Modifier) {
|
private fun HtmlBody(
|
||||||
Column(
|
body: Element,
|
||||||
modifier = modifier
|
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()) {
|
var sameRow = true
|
||||||
when (node) {
|
while (sameRow && nodes.hasNext()) {
|
||||||
|
when (val node = nodes.next()) {
|
||||||
is TextNode -> {
|
is TextNode -> {
|
||||||
if (!node.isBlank) {
|
if (!node.isBlank) {
|
||||||
Text(text = node.text())
|
Text(text = node.text())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Element -> {
|
is Element -> {
|
||||||
HtmlBlock(element = node)
|
if (node.isInline()) {
|
||||||
}
|
HtmlInline(
|
||||||
else -> {
|
node,
|
||||||
continue
|
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 Element.isInline(): Boolean {
|
||||||
private fun HtmlBlock(element: Element, modifier: Modifier = Modifier) {
|
return when (normalName()) {
|
||||||
val blockModifier = modifier
|
"del" -> true
|
||||||
.padding(top = 4.dp)
|
"mx-reply" -> false
|
||||||
when (element.normalName()) {
|
else -> !isBlock
|
||||||
"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
|
@Composable
|
||||||
private fun HtmlInline(element: Element, modifier: Modifier = Modifier) {
|
private fun HtmlBlock(
|
||||||
Box(modifier.padding(start = 8.dp)) {
|
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 {
|
val styledText = buildAnnotatedString {
|
||||||
appendInlineElement(element, MaterialTheme.colorScheme)
|
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
|
@Composable
|
||||||
private fun HtmlParagraph(paragraph: Element, modifier: Modifier = Modifier) {
|
private fun HtmlParagraph(
|
||||||
|
paragraph: Element,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onTextClicked: () -> Unit,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
|
) {
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
val styledText = buildAnnotatedString {
|
val styledText = buildAnnotatedString {
|
||||||
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
|
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
|
||||||
}
|
}
|
||||||
Text(styledText)
|
HtmlText(text = styledText, onClick = onTextClicked, interactionSource = interactionSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
val color = MaterialTheme.colorScheme.onBackground
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -129,13 +244,18 @@ private fun HtmlBlockquote(blockquote: Element, modifier: Modifier = Modifier) {
|
||||||
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
|
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(text)
|
HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@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()) {
|
val style = when (heading.normalName()) {
|
||||||
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
|
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
|
||||||
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
|
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
|
||||||
|
|
@ -151,16 +271,28 @@ private fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) {
|
||||||
val text = buildAnnotatedString {
|
val text = buildAnnotatedString {
|
||||||
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
|
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
|
||||||
}
|
}
|
||||||
Text(text, style = style)
|
HtmlText(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
onClick = onTextClicked,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 blockquote = mxReply.childNodes().firstOrNull() ?: return
|
||||||
val shape = RoundedCornerShape(12.dp)
|
val shape = RoundedCornerShape(12.dp)
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.offset(x = -(8.dp)),
|
modifier = modifier
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
.offset(x = -(8.dp)),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
shape = shape,
|
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
|
@Composable
|
||||||
private fun HtmlOrderedList(unorderedList: Element, modifier: Modifier = Modifier) {
|
private fun HtmlOrderedList(
|
||||||
|
orderedList: Element,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onTextClicked: () -> Unit,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
|
) {
|
||||||
var number = 1
|
var number = 1
|
||||||
val delimiter = "."
|
val delimiter = "."
|
||||||
HtmlListItems(unorderedList, modifier = modifier) {
|
HtmlListItems(
|
||||||
|
list = orderedList,
|
||||||
|
modifier = modifier,
|
||||||
|
onTextClicked = onTextClicked,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
) {
|
||||||
val text = buildAnnotatedString {
|
val text = buildAnnotatedString {
|
||||||
append("${number++}$delimiter ${it.text()}")
|
append("${number++}$delimiter ${it.text()}")
|
||||||
}
|
}
|
||||||
Text(text)
|
HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modifier) {
|
private fun HtmlUnorderedList(
|
||||||
|
unorderedList: Element,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onTextClicked: () -> Unit,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
|
) {
|
||||||
val marker = "・"
|
val marker = "・"
|
||||||
HtmlListItems(unorderedList, modifier = modifier) {
|
HtmlListItems(
|
||||||
|
list = unorderedList,
|
||||||
|
modifier = modifier,
|
||||||
|
onTextClicked = onTextClicked,
|
||||||
|
interactionSource = interactionSource
|
||||||
|
) {
|
||||||
val text = buildAnnotatedString {
|
val text = buildAnnotatedString {
|
||||||
append("$marker ${it.text()}")
|
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(
|
private fun HtmlListItems(
|
||||||
list: Element,
|
list: Element,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onTextClicked: () -> Unit,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
content: @Composable (node: TextNode) -> Unit
|
content: @Composable (node: TextNode) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
|
|
@ -233,7 +392,9 @@ private fun HtmlListItems(
|
||||||
}
|
}
|
||||||
is Element -> HtmlBlock(
|
is Element -> HtmlBlock(
|
||||||
element = innerNode,
|
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" -> {
|
"a" -> {
|
||||||
val href = element.attr("href")
|
appendLink(element)
|
||||||
pushStringAnnotation(tag = "url", annotation = href)
|
|
||||||
withStyle(
|
|
||||||
style = SpanStyle(
|
|
||||||
color = Color.Blue,
|
|
||||||
textDecoration = TextDecoration.Underline
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
append(element.ownText())
|
|
||||||
}
|
|
||||||
pop()
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
appendInlineChildrenElements(element.childNodes(), colors)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.x.matrix.core
|
||||||
|
|
||||||
|
import io.element.android.x.sdk.matrix.BuildConfig
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class contains pattern to match the different Matrix ids
|
||||||
|
* Ref: https://matrix.org/docs/spec/appendices#identifier-grammar
|
||||||
|
*/
|
||||||
|
object MatrixPatterns {
|
||||||
|
|
||||||
|
// Note: TLD is not mandatory (localhost, IP address...)
|
||||||
|
private const val DOMAIN_REGEX = ":[A-Z0-9.-]+(:[0-9]{2,5})?"
|
||||||
|
|
||||||
|
// regex pattern to find matrix user ids in a string.
|
||||||
|
// See https://matrix.org/docs/spec/appendices#historical-user-ids
|
||||||
|
private const val MATRIX_USER_IDENTIFIER_REGEX = "@[A-Z0-9\\x21-\\x39\\x3B-\\x7F]+$DOMAIN_REGEX"
|
||||||
|
val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// regex pattern to find room ids in a string.
|
||||||
|
private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9]+$DOMAIN_REGEX"
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// regex pattern to find room aliases in a string.
|
||||||
|
private const val MATRIX_ROOM_ALIAS_REGEX = "#[A-Z0-9._%#@=+-]+$DOMAIN_REGEX"
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// regex pattern to find message ids in a string.
|
||||||
|
private const val MATRIX_EVENT_IDENTIFIER_REGEX = "\\$[A-Z0-9]+$DOMAIN_REGEX"
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// regex pattern to find message ids in a string.
|
||||||
|
private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$[A-Z0-9/+]+"
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// Ref: https://matrix.org/docs/spec/rooms/v4#event-ids
|
||||||
|
private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$[A-Z0-9\\-_]+"
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// regex pattern to find group ids in a string.
|
||||||
|
private const val MATRIX_GROUP_IDENTIFIER_REGEX = "\\+[A-Z0-9=_\\-./]+$DOMAIN_REGEX"
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER = MATRIX_GROUP_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// regex pattern to find permalink with message id.
|
||||||
|
// Android does not support in URL so extract it.
|
||||||
|
private const val PERMALINK_BASE_REGEX = "https://matrix\\.to/#/"
|
||||||
|
private const val APP_BASE_REGEX = "https://[A-Z0-9.-]+\\.[A-Z]{2,}/[A-Z]{3,}/#/room/"
|
||||||
|
const val SEP_REGEX = "/"
|
||||||
|
|
||||||
|
private const val LINK_TO_ROOM_ID_REGEXP = PERMALINK_BASE_REGEX + MATRIX_ROOM_IDENTIFIER_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID = LINK_TO_ROOM_ID_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private const val LINK_TO_ROOM_ALIAS_REGEXP = PERMALINK_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||||
|
private val PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ALIAS = LINK_TO_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private const val LINK_TO_APP_ROOM_ID_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_IDENTIFIER_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||||
|
private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ID = LINK_TO_APP_ROOM_ID_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
private const val LINK_TO_APP_ROOM_ALIAS_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||||
|
private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS = LINK_TO_APP_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
// ascii characters in the range \x20 (space) to \x7E (~)
|
||||||
|
val ORDER_STRING_REGEX = "[ -~]+".toRegex()
|
||||||
|
|
||||||
|
// list of patterns to find some matrix item.
|
||||||
|
val MATRIX_PATTERNS = listOf(
|
||||||
|
PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID,
|
||||||
|
PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ALIAS,
|
||||||
|
PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ID,
|
||||||
|
PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS,
|
||||||
|
PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER,
|
||||||
|
PATTERN_CONTAIN_MATRIX_ALIAS,
|
||||||
|
PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER,
|
||||||
|
PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER,
|
||||||
|
PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells if a string is a valid user Id.
|
||||||
|
*
|
||||||
|
* @param str the string to test
|
||||||
|
* @return true if the string is a valid user id
|
||||||
|
*/
|
||||||
|
fun isUserId(str: String?): Boolean {
|
||||||
|
return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells if a string is a valid room id.
|
||||||
|
*
|
||||||
|
* @param str the string to test
|
||||||
|
* @return true if the string is a valid room Id
|
||||||
|
*/
|
||||||
|
fun isRoomId(str: String?): Boolean {
|
||||||
|
return str != null && str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells if a string is a valid room alias.
|
||||||
|
*
|
||||||
|
* @param str the string to test
|
||||||
|
* @return true if the string is a valid room alias.
|
||||||
|
*/
|
||||||
|
fun isRoomAlias(str: String?): Boolean {
|
||||||
|
return str != null && str matches PATTERN_CONTAIN_MATRIX_ALIAS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells if a string is a valid event id.
|
||||||
|
*
|
||||||
|
* @param str the string to test
|
||||||
|
* @return true if the string is a valid event id.
|
||||||
|
*/
|
||||||
|
fun isEventId(str: String?): Boolean {
|
||||||
|
return str != null &&
|
||||||
|
(str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER ||
|
||||||
|
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 ||
|
||||||
|
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells if a string is a valid group id.
|
||||||
|
*
|
||||||
|
* @param str the string to test
|
||||||
|
* @return true if the string is a valid group id.
|
||||||
|
*/
|
||||||
|
fun isGroupId(str: String?): Boolean {
|
||||||
|
return str != null && str matches PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract server name from a matrix id.
|
||||||
|
*
|
||||||
|
* @param matrixId
|
||||||
|
* @return null if not found or if matrixId is null
|
||||||
|
*/
|
||||||
|
fun extractServerNameFromId(matrixId: String?): String? {
|
||||||
|
return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract user name from a matrix id.
|
||||||
|
*
|
||||||
|
* @param matrixId
|
||||||
|
* @return null if the input is not a valid matrixId
|
||||||
|
*/
|
||||||
|
fun extractUserNameFromId(matrixId: String): String? {
|
||||||
|
return if (isUserId(matrixId)) {
|
||||||
|
matrixId.removePrefix("@").substringBefore(":", missingDelimiterValue = "")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7E (~),
|
||||||
|
* or consist of more than 50 characters, are forbidden and the field should be ignored if received.
|
||||||
|
*/
|
||||||
|
fun isValidOrderString(order: String?): Boolean {
|
||||||
|
return order != null && order.length < 50 && order matches ORDER_STRING_REGEX
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
fun candidateAliasFromRoomName(roomName: String, domain: String): String {
|
||||||
|
return roomName.lowercase()
|
||||||
|
.replaceSpaceChars(replacement = "_")
|
||||||
|
.removeInvalidRoomNameChars()
|
||||||
|
.take(MatrixConstants.maxAliasLocalPartLength(domain))
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the domain form a userId.
|
||||||
|
* Examples:
|
||||||
|
* - "@alice:domain.org".getDomain() will return "domain.org"
|
||||||
|
* - "@bob:domain.org:3455".getDomain() will return "domain.org:3455"
|
||||||
|
*/
|
||||||
|
fun String.getServerName(): String {
|
||||||
|
if (BuildConfig.DEBUG && !isUserId(this)) {
|
||||||
|
// They are some invalid userId localpart in the wild, but the domain part should be there anyway
|
||||||
|
Timber.w("Not a valid user ID: $this")
|
||||||
|
}
|
||||||
|
return substringAfter(":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package io.element.android.x.matrix.permalink
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of an input URI to a matrix.to compliant URI.
|
||||||
|
*/
|
||||||
|
object MatrixToConverter {
|
||||||
|
|
||||||
|
const val MATRIX_TO_URL_BASE = "https://matrix.to/#/"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
|
||||||
|
* To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
|
||||||
|
* Examples:
|
||||||
|
* - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
|
||||||
|
* - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
|
||||||
|
* - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
|
||||||
|
*/
|
||||||
|
fun convert(uri: Uri): Uri? {
|
||||||
|
val uriString = uri.toString()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// URL is already a matrix.to
|
||||||
|
uriString.startsWith(MATRIX_TO_URL_BASE) -> uri
|
||||||
|
// Web or client url
|
||||||
|
SUPPORTED_PATHS.any { it in uriString } -> {
|
||||||
|
val path = SUPPORTED_PATHS.first { it in uriString }
|
||||||
|
Uri.parse(MATRIX_TO_URL_BASE + uriString.substringAfter(path))
|
||||||
|
}
|
||||||
|
// URL is not supported
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SUPPORTED_PATHS = listOf(
|
||||||
|
"/#/room/",
|
||||||
|
"/#/user/",
|
||||||
|
"/#/group/"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package io.element.android.x.matrix.permalink
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This sealed class represents all the permalink cases.
|
||||||
|
* You don't have to instantiate yourself but should use [PermalinkParser] instead.
|
||||||
|
*/
|
||||||
|
sealed class PermalinkData {
|
||||||
|
|
||||||
|
data class RoomLink(
|
||||||
|
val roomIdOrAlias: String,
|
||||||
|
val isRoomAlias: Boolean,
|
||||||
|
val eventId: String?,
|
||||||
|
val viaParameters: List<String>
|
||||||
|
) : PermalinkData()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* &room_name=Team2
|
||||||
|
* &room_avatar_url=mxc:
|
||||||
|
* &inviter_name=bob
|
||||||
|
*/
|
||||||
|
data class RoomEmailInviteLink(
|
||||||
|
val roomId: String,
|
||||||
|
val email: String,
|
||||||
|
val signUrl: String,
|
||||||
|
val roomName: String?,
|
||||||
|
val roomAvatarUrl: String?,
|
||||||
|
val inviterName: String?,
|
||||||
|
val identityServer: String,
|
||||||
|
val token: String,
|
||||||
|
val privateKey: String,
|
||||||
|
val roomType: String?
|
||||||
|
) : PermalinkData()
|
||||||
|
|
||||||
|
data class UserLink(val userId: String) : PermalinkData()
|
||||||
|
|
||||||
|
data class FallbackLink(val uri: Uri, val isLegacyGroupLink: Boolean = false) : PermalinkData()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package io.element.android.x.matrix.permalink
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.net.UrlQuerySanitizer
|
||||||
|
import io.element.android.x.matrix.core.MatrixPatterns
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class turns a uri to a [PermalinkData].
|
||||||
|
* element-based domains (e.g. https://app.element.io/#/user/@chagai95:matrix.org) permalinks
|
||||||
|
* or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
|
||||||
|
* or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org)
|
||||||
|
*/
|
||||||
|
object PermalinkParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a uri string to a [PermalinkData].
|
||||||
|
*/
|
||||||
|
fun parse(uriString: String): PermalinkData {
|
||||||
|
val uri = Uri.parse(uriString)
|
||||||
|
return parse(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a uri to a [PermalinkData].
|
||||||
|
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
|
||||||
|
*/
|
||||||
|
fun parse(uri: Uri): PermalinkData {
|
||||||
|
// the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the
|
||||||
|
// mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid
|
||||||
|
// so convert URI to matrix.to to simplify parsing process
|
||||||
|
val matrixToUri = MatrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
|
||||||
|
|
||||||
|
// We can't use uri.fragment as it is decoding to early and it will break the parsing
|
||||||
|
// of parameters that represents url (like signurl)
|
||||||
|
val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment
|
||||||
|
if (fragment.isEmpty()) {
|
||||||
|
return PermalinkData.FallbackLink(uri)
|
||||||
|
}
|
||||||
|
val safeFragment = fragment.substringBefore('?')
|
||||||
|
val viaQueryParameters = fragment.getViaParameters()
|
||||||
|
|
||||||
|
// we are limiting to 2 params
|
||||||
|
val params = safeFragment
|
||||||
|
.split(MatrixPatterns.SEP_REGEX)
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.take(2)
|
||||||
|
|
||||||
|
val decodedParams = params
|
||||||
|
.map { URLDecoder.decode(it, "UTF-8") }
|
||||||
|
|
||||||
|
val identifier = params.getOrNull(0)
|
||||||
|
val decodedIdentifier = decodedParams.getOrNull(0)
|
||||||
|
val extraParameter = decodedParams.getOrNull(1)
|
||||||
|
return when {
|
||||||
|
identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri)
|
||||||
|
MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier)
|
||||||
|
MatrixPatterns.isRoomId(decodedIdentifier) -> {
|
||||||
|
handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters)
|
||||||
|
}
|
||||||
|
MatrixPatterns.isRoomAlias(decodedIdentifier) -> {
|
||||||
|
PermalinkData.RoomLink(
|
||||||
|
roomIdOrAlias = decodedIdentifier,
|
||||||
|
isRoomAlias = true,
|
||||||
|
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
|
||||||
|
viaParameters = viaQueryParameters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List<String>): PermalinkData {
|
||||||
|
// Can't rely on built in parsing because it's messing around the signurl
|
||||||
|
val paramList = safeExtractParams(fragment)
|
||||||
|
val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second
|
||||||
|
val email = paramList.firstOrNull { it.first == "email" }?.second
|
||||||
|
return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) {
|
||||||
|
try {
|
||||||
|
val signValidUri = Uri.parse(signUrl)
|
||||||
|
val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException()
|
||||||
|
val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException()
|
||||||
|
val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException()
|
||||||
|
PermalinkData.RoomEmailInviteLink(
|
||||||
|
roomId = identifier,
|
||||||
|
email = email!!,
|
||||||
|
signUrl = signUrl!!,
|
||||||
|
roomName = paramList.firstOrNull { it.first == "room_name" }?.second,
|
||||||
|
inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second,
|
||||||
|
roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second,
|
||||||
|
roomType = paramList.firstOrNull { it.first == "room_type" }?.second,
|
||||||
|
identityServer = identityServerHost,
|
||||||
|
token = token,
|
||||||
|
privateKey = privateKey
|
||||||
|
)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
Timber.i("## Permalink: Failed to parse permalink $signUrl")
|
||||||
|
PermalinkData.FallbackLink(uri)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PermalinkData.RoomLink(
|
||||||
|
roomIdOrAlias = identifier,
|
||||||
|
isRoomAlias = false,
|
||||||
|
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
|
||||||
|
viaParameters = viaQueryParameters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun safeExtractParams(fragment: String) =
|
||||||
|
fragment.substringAfter("?").split('&').mapNotNull {
|
||||||
|
val splitNameValue = it.split("=")
|
||||||
|
if (splitNameValue.size == 2) {
|
||||||
|
Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8"))
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.getViaParameters(): List<String> {
|
||||||
|
return UrlQuerySanitizer(this)
|
||||||
|
.parameterList
|
||||||
|
.filter {
|
||||||
|
it.mParameter == "via"
|
||||||
|
}.map {
|
||||||
|
URLDecoder.decode(it.mValue, "UTF-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue