diff --git a/app/src/main/java/chat/revolt/components/markdown/jbm/JBMRenderer.kt b/app/src/main/java/chat/revolt/components/markdown/jbm/JBMRenderer.kt index 3d56e9c7..163df5e2 100644 --- a/app/src/main/java/chat/revolt/components/markdown/jbm/JBMRenderer.kt +++ b/app/src/main/java/chat/revolt/components/markdown/jbm/JBMRenderer.kt @@ -1,7 +1,11 @@ package chat.revolt.components.markdown.jbm +import android.content.Intent import android.content.res.Configuration import android.util.Log +import android.widget.Toast +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll @@ -9,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -25,17 +30,21 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -50,8 +59,13 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import chat.revolt.R +import chat.revolt.activities.InviteActivity +import chat.revolt.api.schemas.isInviteUri import chat.revolt.api.settings.LoadedSettings +import chat.revolt.callbacks.Action +import chat.revolt.callbacks.ActionChannel import chat.revolt.components.markdown.Annotations import chat.revolt.components.utils.detectTapGesturesConditionalConsume import chat.revolt.ui.theme.FragmentMono @@ -62,6 +76,7 @@ import dev.snipme.highlights.model.CodeHighlight import dev.snipme.highlights.model.ColorHighlight import dev.snipme.highlights.model.SyntaxLanguage import dev.snipme.highlights.model.SyntaxThemes +import kotlinx.coroutines.launch import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode @@ -69,6 +84,19 @@ import org.intellij.markdown.ast.getTextInNode import org.intellij.markdown.flavours.gfm.GFMElementTypes import org.intellij.markdown.flavours.gfm.GFMTokenTypes +enum class JBMAnnotations(val tag: String, val clickable: Boolean) { + URL("URL", true), + UserMention("UserMention", true), + ChannelMention("ChannelMention", true), + CustomEmote("CustomEmote", true), + Timestamp("Timestamp", false) +} + +data class JBMColors( + val clickable: Color, + val clickableBackground: Color, +) + data class JBMarkdownTreeState( val sourceText: String = "", val listDepth: Int = 0, @@ -76,6 +104,10 @@ data class JBMarkdownTreeState( val linksClickable: Boolean = true, val currentServer: String? = null, val embedded: Boolean = false, + val colors: JBMColors = JBMColors( + clickable = Color(0xFFFF00FF), + clickableBackground = Color(0x2000FF00) + ) ) val LocalJBMarkdownTreeState = @@ -92,7 +124,11 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) { CompositionLocalProvider( LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy( - sourceText = content + sourceText = content, + colors = JBMColors( + clickable = MaterialTheme.colorScheme.primary, + clickableBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) ) ) { if (LocalJBMarkdownTreeState.current.embedded) { @@ -214,13 +250,55 @@ private fun annotateText( } MarkdownElementTypes.PARAGRAPH, - MarkdownElementTypes.HTML_BLOCK, - MarkdownTokenTypes.ATX_CONTENT -> { + MarkdownElementTypes.HTML_BLOCK -> { for (child in node.children) { append(annotateText(state, child)) } } + MarkdownTokenTypes.ATX_CONTENT -> { + // Drop WHITE_SPACE children at the start + for (child in node.children.dropWhile { it.type == MarkdownTokenTypes.WHITE_SPACE }) { + append(annotateText(state, child)) + } + } + + MarkdownElementTypes.INLINE_LINK -> { + val linkTextChild = + node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT } + val linkDestinationChild = + node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } + ?: node.children.firstOrNull { it.type == MarkdownElementTypes.AUTOLINK } + + pushStringAnnotation( + tag = JBMAnnotations.URL.tag, + annotation = linkDestinationChild?.getTextInNode(sourceText).toString() + .removeSurrounding("<", ">") + ) + pushStyle(SpanStyle(color = state.colors.clickable)) + linkTextChild?.children + ?.drop(1) // l-bracket + ?.dropLast(1) // r-bracket + ?.forEach { + append(annotateText(state, it)) + } + pop() + pop() + } + + GFMTokenTypes.GFM_AUTOLINK, + MarkdownTokenTypes.AUTOLINK -> { + pushStringAnnotation( + tag = JBMAnnotations.URL.tag, + annotation = node.getTextInNode(sourceText).toString() + .removeSurrounding("<", ">") + ) + pushStyle(SpanStyle(color = state.colors.clickable)) + append(node.getTextInNode(sourceText)) + pop() + pop() + } + // re-render types // for example, various syntactic elements like exclamation marks, brackets, etc. // we simply append the text as is @@ -238,14 +316,27 @@ private fun annotateText( MarkdownTokenTypes.WHITE_SPACE, MarkdownTokenTypes.COLON, MarkdownTokenTypes.EMPH, - GFMTokenTypes.TILDE -> { + GFMTokenTypes.TILDE, + GFMTokenTypes.DOLLAR -> { + append(node.getTextInNode(sourceText)) + } + + MarkdownElementTypes.SHORT_REFERENCE_LINK, + MarkdownElementTypes.LINK_DEFINITION, + MarkdownElementTypes.FULL_REFERENCE_LINK -> { append(node.getTextInNode(sourceText)) } else -> { - append("[${node.type.name}]{\n") - append(node.getTextInNode(sourceText)) - append("\n}") + withStyle(SpanStyle(color = Color.Cyan)) { + append("[${node.type.name}]{\n") + } + for (child in node.children) { + append(annotateText(state, child)) + } + withStyle(SpanStyle(color = Color.Cyan)) { + append("\n}") + } } } } @@ -266,9 +357,15 @@ private fun JBMText(node: ASTNode, modifier: Modifier) { val mdState = LocalJBMarkdownTreeState.current val annotatedText = remember(node) { annotateText(mdState, node) } val colours = MaterialTheme.colorScheme + val scope = rememberCoroutineScope() + val context = LocalContext.current val shouldConsumeTap = handler@{ offset: Int -> - Annotations.entries.filter { it.clickable }.map { it.tag }.forEach { tag -> + if (!mdState.linksClickable) { + return@handler false + } + + JBMAnnotations.entries.filter { it.clickable }.map { it.tag }.forEach { tag -> if (annotatedText.getStringAnnotations( tag = tag, start = offset, @@ -284,12 +381,76 @@ private fun JBMText(node: ASTNode, modifier: Modifier) { val onClick = handler@{ offset: Int -> if (mdState.linksClickable) { + JBMAnnotations.entries.filter { it.clickable }.map { it.tag }.forEach { tag -> + val annotations = annotatedText.getStringAnnotations( + tag = tag, + start = offset, + end = offset + ) + annotations.forEach { annotation -> + val item = annotation.item + when (tag) { + JBMAnnotations.URL.tag -> { + try { + val uri = item.toUri() + if (uri.isInviteUri()) { + scope.launch { + Intent(context, InviteActivity::class.java).apply { + data = uri + context.startActivity(this) + } + } + return@handler true + } + } catch (e: Exception) { + // no-op + } + + val customTab = CustomTabsIntent.Builder() + .setShowTitle(true) + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + .setToolbarColor(colours.surfaceContainer.toArgb()) + .build() + ) + .build() + + try { + customTab.launchUrl(context, item.toUri()) + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.link_type_no_intent), + Toast.LENGTH_SHORT + ).show() + } + + return@handler true + } + } + } + } } + + return@handler false } val onLongClick = handler@{ offset: Int -> if (mdState.linksClickable) { + annotatedText.getStringAnnotations( + tag = Annotations.URL.tag, + start = offset, + end = offset + ).firstOrNull()?.let { annotation -> + scope.launch { + ActionChannel.send(Action.LinkInfo(annotation.item)) + } + + return@handler true + } } + + return@handler false } Text( @@ -495,12 +656,15 @@ private fun JBMCodeBlockContent(node: ASTNode, modifier: Modifier) { } @Composable -private fun JBMBlock(node: ASTNode, modifier: Modifier) { +private fun JBMBlock(node: ASTNode, modifier: Modifier, nestingCounter: Int = 0) { val state = LocalJBMarkdownTreeState.current + val colorScheme = MaterialTheme.colorScheme when (node.type) { MarkdownElementTypes.PARAGRAPH, - MarkdownElementTypes.HTML_BLOCK -> { + MarkdownElementTypes.HTML_BLOCK, + MarkdownElementTypes.LINK_DEFINITION, + MarkdownTokenTypes.WHITE_SPACE -> { CompositionLocalProvider( LocalTextStyle provides LocalTextStyle.current.copy( fontSize = LocalTextStyle.current.fontSize * state.fontSizeMultiplier @@ -557,6 +721,49 @@ private fun JBMBlock(node: ASTNode, modifier: Modifier) { JBMCodeBlockContent(node, modifier) } + MarkdownElementTypes.BLOCK_QUOTE -> { + if (LocalJBMarkdownTreeState.current.embedded) { + node.children.getOrNull(0)?.let { + JBMBlock(it, modifier) + } + } else { + Column( + Modifier + .clip(MaterialTheme.shapes.medium) + .fillMaxWidth() + .drawBehind { + drawRect(colorScheme.surfaceContainer.copy(alpha = 0.5f)) + drawLine( + colorScheme.primary, + Offset.Zero, + Offset(0f, size.height), + strokeWidth = 16f + ) + } + .padding(8.dp) + .padding(start = 4.dp) + ) { + if (nestingCounter < 5) { + node.children.map { + JBMBlock(it, modifier, nestingCounter = nestingCounter + 1) + } + } + } + } + } + + MarkdownTokenTypes.BLOCK_QUOTE -> { + if (LocalJBMarkdownTreeState.current.embedded) { + node.children.getOrNull(0)?.let { + JBMBlock(it, modifier) + } + } else { + node.children.map { + JBMBlock(it, modifier) + } + } + } + else -> { Text( text = buildAnnotatedString {