diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt index c6a1b58d65..80cfa45589 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/MessagesScreen.kt @@ -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, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt index f687fdd468..81cc00153c 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessageEventBubble.kt @@ -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, diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt index 3ca1a8217b..5c6d7db275 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/components/MessagesTimelineItemTextView.kt @@ -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) diff --git a/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt b/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt index e04e8dcbc0..d3ec294921 100644 --- a/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt +++ b/features/messages/src/main/java/io/element/android/x/features/messages/components/html/HtmlDocument.kt @@ -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, + 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(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() + Text( + text = text, + modifier = modifier.then(pressIndicator), + style = style, + onTextLayout = { + layoutResult.value = it + }, + inlineContent = inlineContentMap + ) +} + diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/core/MatrixPatterns.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/core/MatrixPatterns.kt new file mode 100644 index 0000000000..ab516d8ba8 --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/core/MatrixPatterns.kt @@ -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(":") + } +} diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/MatrixToConverter.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/MatrixToConverter.kt new file mode 100644 index 0000000000..aeb626d0db --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/MatrixToConverter.kt @@ -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/" + ) +} diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/PermalinkData.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/PermalinkData.kt new file mode 100644 index 0000000000..27e06c876c --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/PermalinkData.kt @@ -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 + ) : 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() +} diff --git a/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/PermalinkParser.kt b/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/PermalinkParser.kt new file mode 100644 index 0000000000..e1f878d331 --- /dev/null +++ b/libraries/matrix/src/main/java/io/element/android/x/matrix/permalink/PermalinkParser.kt @@ -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. 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): 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 { + return UrlQuerySanitizer(this) + .parameterList + .filter { + it.mParameter == "via" + }.map { + URLDecoder.decode(it.mValue, "UTF-8") + } + } +}