feat(jbm): continue development

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-09-13 00:04:00 +02:00
parent 5497933589
commit 7cfbc3fdac
5 changed files with 333 additions and 51 deletions

View File

@ -269,6 +269,7 @@ dependencies {
// Markup
implementation "org.jetbrains:markdown:0.7.3"
implementation "dev.snipme:highlights:0.9.1"
// Livekit
// FIXME temporarily not included, re-add when realtime media is to be implemented

View File

@ -34,13 +34,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -78,6 +82,9 @@ import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
import chat.revolt.components.markdown.LocalMarkdownTreeConfig
import chat.revolt.components.markdown.RichMarkdown
import chat.revolt.components.markdown.jbm.JBM
import chat.revolt.components.markdown.jbm.JBMRenderer
import chat.revolt.components.markdown.jbm.LocalJBMarkdownTreeState
import chat.revolt.internals.text.Gigamoji
import kotlinx.coroutines.launch
import chat.revolt.api.schemas.Message as MessageSchema
@ -167,7 +174,7 @@ fun formatLongAsTime(time: Long): String {
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, JBM::class)
@Composable
fun Message(
message: MessageSchema,
@ -192,6 +199,8 @@ fun Message(
val authorIsBlocked = remember(author) { author.relationship == "Blocked" }
var __TEMPORARY_useJbm by remember { mutableStateOf(false) }
Column(Modifier.animateContentSize()) {
if (message.tail == false) {
Spacer(modifier = Modifier.height(10.dp))
@ -361,17 +370,40 @@ fun Message(
message.content?.let {
if (message.content.isBlank()) return@let // if only an attachment is sent
CompositionLocalProvider(
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
currentServer = RevoltAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(message.content)
.let {
if (it) 2f else 1f
}
)
) {
Spacer(modifier = Modifier.height(2.dp))
RichMarkdown(input = message.content)
Switch(
checked = __TEMPORARY_useJbm,
onCheckedChange = { __TEMPORARY_useJbm = it },
)
if (__TEMPORARY_useJbm == false) {
CompositionLocalProvider(
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
currentServer = RevoltAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
message.content
)
.let {
if (it) 2f else 1f
}
)
) {
Spacer(modifier = Modifier.height(2.dp))
RichMarkdown(input = message.content)
}
} else {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
message.content
)
.let {
if (it) 2f else 1f
}
)
) {
Spacer(modifier = Modifier.height(2.dp))
JBMRenderer(message.content)
}
}
}
}

View File

@ -1,10 +1,18 @@
package chat.revolt.components.markdown.jbm
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalTextStyle
@ -20,12 +28,14 @@ import androidx.compose.runtime.remember
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.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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
@ -39,8 +49,17 @@ 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 chat.revolt.api.settings.GlobalState
import chat.revolt.components.markdown.Annotations
import chat.revolt.components.utils.detectTapGesturesConditionalConsume
import chat.revolt.ui.theme.FragmentMono
import chat.revolt.ui.theme.isThemeDark
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
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 org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
@ -50,10 +69,11 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
data class JBMarkdownTreeState(
val sourceText: String = "",
val ignoreLineBreaks: Boolean = false,
val listDepth: Int = 0,
val fontSizeMultiplier: Float = 1f,
val linksClickable: Boolean = true
val linksClickable: Boolean = true,
val currentServer: String? = null,
val embedded: Boolean = false,
)
val LocalJBMarkdownTreeState =
@ -66,15 +86,21 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
LaunchedEffect(content) {
tree = JBMApi.parse(content)
Log.d("JBMRenderer", "Parsed tree: ${tree.children.map { it.type.name }}")
}
CompositionLocalProvider(
LocalJBMarkdownTreeState provides JBMarkdownTreeState(content)
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
sourceText = content
)
) {
tree.children.map {
JBMBlock(it, modifier)
if (LocalJBMarkdownTreeState.current.embedded) {
tree.children.getOrNull(0)?.let {
JBMBlock(it, modifier)
}
} else {
tree.children.map {
JBMBlock(it, modifier)
}
}
}
}
@ -89,12 +115,34 @@ private fun annotateText(
buildAnnotatedString {
when (node.type) {
MarkdownTokenTypes.TEXT -> {
append(node.getTextInNode(sourceText))
val source = if (state.embedded) {
node.getTextInNode(sourceText).toString().replace("\n", " ")
} else {
node.getTextInNode(sourceText)
}
append(source)
}
MarkdownTokenTypes.ATX_HEADER -> {
// Do not need to do anything
}
MarkdownElementTypes.ATX_1,
MarkdownElementTypes.ATX_2,
MarkdownElementTypes.ATX_3,
MarkdownElementTypes.ATX_4,
MarkdownElementTypes.ATX_5,
MarkdownElementTypes.ATX_6 -> {
for (child in node.children) {
append(annotateText(state, child))
}
}
MarkdownElementTypes.EMPH -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
for (child in node.children) {
// Skip the first child and the last child
// because they are the asterisk characters
for (child in node.children.subList(1, node.children.size - 1)) {
append(annotateText(state, child))
}
}
@ -102,7 +150,9 @@ private fun annotateText(
MarkdownElementTypes.STRONG -> {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
for (child in node.children) {
// Skip the first two children and the last two children
// because they are the asterisk characters
for (child in node.children.subList(2, node.children.size - 2)) {
append(annotateText(state, child))
}
}
@ -118,8 +168,24 @@ private fun annotateText(
}
}
MarkdownElementTypes.CODE_SPAN -> {
withStyle(SpanStyle(fontFamily = FragmentMono)) {
val startsWithTwoBackticks =
node.children.getOrNull(1)?.type == MarkdownTokenTypes.BACKTICK
val removeItemCount = if (startsWithTwoBackticks) 2 else 1
// Skip the first and last 1 or 2 children
// because they are the backtick characters
for (child in node.children.subList(
removeItemCount,
node.children.size - removeItemCount
)) {
append(annotateText(state, child))
}
}
}
MarkdownTokenTypes.LIST_BULLET -> {
append(" ".repeat(state.listDepth) + " " + if (state.listDepth % 2 == 0) "" else "" + " ")
append(" ".repeat(state.listDepth) + " ")
}
MarkdownTokenTypes.LIST_NUMBER -> {
@ -145,7 +211,9 @@ private fun annotateText(
append(" ")
}
MarkdownElementTypes.PARAGRAPH, MarkdownElementTypes.HTML_BLOCK -> {
MarkdownElementTypes.PARAGRAPH,
MarkdownElementTypes.HTML_BLOCK,
MarkdownTokenTypes.ATX_CONTENT -> {
for (child in node.children) {
append(annotateText(state, child))
}
@ -167,15 +235,11 @@ private fun annotateText(
MarkdownTokenTypes.EOL,
MarkdownTokenTypes.WHITE_SPACE,
MarkdownTokenTypes.COLON,
MarkdownTokenTypes.EMPH,
GFMTokenTypes.TILDE -> {
append(node.getTextInNode(sourceText))
}
// no-op types
// for example, the special characters that are used to denote the markup are here
MarkdownTokenTypes.EMPH -> {
}
else -> {
append("[${node.type.name}]{\n")
append(node.getTextInNode(sourceText))
@ -292,14 +356,132 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
)
}
private fun annotateHighlights(
source: String,
highlights: List<CodeHighlight>
): AnnotatedString {
val highlightStyles = highlights.map {
when (it) {
is BoldHighlight -> AnnotatedString.Range(
SpanStyle(fontWeight = FontWeight.Bold),
it.location.start,
it.location.end
)
is ColorHighlight -> {
AnnotatedString.Range(
SpanStyle(color = Color(0xFF000000 or it.rgb.toLong())),
it.location.start,
it.location.end
)
}
else -> null
}
}.filterNotNull()
return AnnotatedString(source, spanStyles = highlightStyles)
}
// ======== TODO ========
// - Add aliases for languages. For example, "js" should be an alias for "javascript" and "ts" should be an alias for "typescript", etc.
// - Better looking language name display. Ideally a dictionary of language names to display names with proper brand casing.
@Composable
private fun JBMCodeBlockContent(node: ASTNode, modifier: Modifier) {
val state = LocalJBMarkdownTreeState.current
/*val colours = MaterialTheme.colorScheme
val contentColour = LocalContentColor.current
val syntaxTheme = remember {
SyntaxTheme(
key = "chat.revolt.M3Dynamic",
code = contentColour.toArgb() and 0xFFFFFF,
comment = colours.outline.toArgb() and 0xFFFFFF,
multilineComment = colours.outline.toArgb() and 0xFFFFFF,
keyword = colours.primary.toArgb() and 0xFFFFFF,
string = colours.secondary.toArgb() and 0xFFFFFF,
literal = colours.tertiary.toArgb() and 0xFFFFFF,
mark = colours.error.toArgb() and 0xFFFFFF,
punctuation = colours.inversePrimary.toArgb() and 0xFFFFFF,
metadata = colours.inverseSurface.toArgb() and 0xFFFFFF,
)
}*/
val uiMode = LocalConfiguration.current.uiMode
val systemIsDark =
(uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val themeIsDark = remember(GlobalState.theme) { isThemeDark(GlobalState.theme, systemIsDark) }
val codeFenceLanguage = remember(node) {
node.children.firstOrNull { it.type == MarkdownTokenTypes.FENCE_LANG }
?.getTextInNode(state.sourceText)?.toString()
}
val codeFenceContent = remember(node) {
node.children
.filter { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT || it.type == MarkdownTokenTypes.EOL }
.joinToString("") {
it.getTextInNode(state.sourceText).toString()
}
.trim()
}
val annotatedContent = remember(codeFenceLanguage, codeFenceContent) {
val canAnnotate = codeFenceLanguage != null
val language = codeFenceLanguage?.let { SyntaxLanguage.getByName(it) }
val shouldAnnotate = language != null
if (canAnnotate && shouldAnnotate) {
buildAnnotatedString {
val highlights = Highlights.Builder().apply {
code(codeFenceContent)
language(language ?: SyntaxLanguage.DEFAULT)
theme(SyntaxThemes.notepad(themeIsDark))
//theme(syntaxTheme)
}.build()
append(annotateHighlights(codeFenceContent, highlights.getHighlights()))
}
} else {
buildAnnotatedString {
append(codeFenceContent)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp)
) {
if (codeFenceLanguage != null) {
Text(
text = codeFenceLanguage,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp)
)
}
Box(
modifier = Modifier.horizontalScroll(rememberScrollState())
) {
Text(
text = annotatedContent,
fontFamily = FragmentMono,
modifier = Modifier
)
}
}
}
@Composable
private fun JBMBlock(node: ASTNode, modifier: Modifier) {
val state = LocalJBMarkdownTreeState.current
when (node.type) {
MarkdownElementTypes.PARAGRAPH,
MarkdownElementTypes.HTML_BLOCK -> {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
fontSize = LocalTextStyle.current.fontSize * LocalJBMarkdownTreeState.current.fontSizeMultiplier
fontSize = LocalTextStyle.current.fontSize * state.fontSizeMultiplier
)
) {
JBMText(node, modifier)
@ -315,42 +497,44 @@ private fun JBMBlock(node: ASTNode, modifier: Modifier) {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
fontWeight = FontWeight.Bold,
fontSize = when (node.type) {
MarkdownElementTypes.ATX_1 -> 32.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier
MarkdownElementTypes.ATX_2 -> 24.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier
MarkdownElementTypes.ATX_3 -> 20.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier
MarkdownElementTypes.ATX_4 -> 16.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier
MarkdownElementTypes.ATX_5 -> 14.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier
else -> 12.sp * LocalJBMarkdownTreeState.current.fontSizeMultiplier
},
color = when (node.type) {
MarkdownElementTypes.ATX_1 -> Color(0xFFFF0000)
MarkdownElementTypes.ATX_2 -> Color(0xFF00FF00)
MarkdownElementTypes.ATX_3 -> Color(0xFF0000FF)
MarkdownElementTypes.ATX_4 -> Color(0xFFFF00FF)
MarkdownElementTypes.ATX_5 -> Color(0xFF00FFFF)
else -> Color(0xFFFFFF00)
fontSize = if (state.embedded) LocalTextStyle.current.fontSize
else when (node.type) {
MarkdownElementTypes.ATX_1 -> 32.sp * state.fontSizeMultiplier
MarkdownElementTypes.ATX_2 -> 24.sp * state.fontSizeMultiplier
MarkdownElementTypes.ATX_3 -> 20.sp * state.fontSizeMultiplier
MarkdownElementTypes.ATX_4 -> 16.sp * state.fontSizeMultiplier
MarkdownElementTypes.ATX_5 -> 14.sp * state.fontSizeMultiplier
else -> 12.sp * state.fontSizeMultiplier
}
)
) {
if (node.startOffset != 0) {
Box(Modifier.padding(top = 8.dp))
Spacer(Modifier.height(8.dp))
}
JBMText(node, modifier)
Spacer(Modifier.height(4.dp))
}
}
MarkdownElementTypes.ORDERED_LIST,
MarkdownElementTypes.UNORDERED_LIST -> {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
listDepth = LocalJBMarkdownTreeState.current.listDepth + 1
LocalJBMarkdownTreeState provides state.copy(
listDepth = state.listDepth + 1
)
) {
JBMText(node, modifier)
}
}
MarkdownTokenTypes.EOL -> {
Spacer(Modifier.height(4.dp))
}
MarkdownElementTypes.CODE_FENCE -> {
JBMCodeBlockContent(node, modifier)
}
else -> {
Text(
text = buildAnnotatedString {

View File

@ -1,19 +1,27 @@
package chat.revolt.screens.labs.ui.sandbox
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.revolt.components.markdown.jbm.JBM
import chat.revolt.components.markdown.jbm.JBMRenderer
import chat.revolt.components.markdown.jbm.LocalJBMarkdownTreeState
import chat.revolt.settings.dsl.SettingsPage
@OptIn(JBM::class)
@ -21,6 +29,7 @@ import chat.revolt.settings.dsl.SettingsPage
fun JBMSandbox(navController: NavController) {
var mdSource by remember { mutableStateOf("") }
var submitMdSource by remember { mutableStateOf<String?>(null) }
var isEmbedded by remember { mutableStateOf(false) }
SettingsPage(
navController = navController,
@ -32,6 +41,20 @@ fun JBMSandbox(navController: NavController) {
)
}
) {
Subcategory(
title = { Text("Options", maxLines = 1, overflow = TextOverflow.Ellipsis) },
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isEmbedded,
onCheckedChange = { isEmbedded = it }
)
Spacer(Modifier.width(8.dp))
Text("Embedded", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Subcategory(
title = { Text("Source", maxLines = 1, overflow = TextOverflow.Ellipsis) },
) {
@ -47,12 +70,43 @@ fun JBMSandbox(navController: NavController) {
}) {
Text("Submit")
}
TextButton(onClick = {
submitMdSource = """# Full range of MD now supported!
1. Text with **bold**, *italics*, and ***both***!
2. You ~~can't see me~~.
3. [I'm a link to another website.](<https://revolt.chat>)
4. I'm a spoiler with ||**bold text inside it**||
- I'm a sub-item on this list...
- Let's go even deeper...
`Inline code`
```js
let x = "I'm a multi-line code block!";
```
> > ${'$'}${'$'}E = mc^2${'$'}${'$'}
>
> Albert Einstein
| Timestamp | Mention | Channel Link | Message Link |
|:-:|:-:|:-:|:-:|
| <t:1663846662:f> | <@01EX2NCWQ0CHS3QJF0FEQS1GR4> | <#01H73F4RAHTPBHKJ1XBQDXK3NQ> | https://revolt.chat/server/01F7ZSBSFHQ8TA81725KQCSDDP/channel/01F92C5ZXBQWQ8KY7J8KY917NM/01J25XZM9JXVVJDDKFPB7Q48HZ |"""
}) {
Text("Submit test document")
}
}
Subcategory(
title = { Text("Output", maxLines = 1, overflow = TextOverflow.Ellipsis) },
) {
submitMdSource?.let { JBMRenderer(it, Modifier) }
?: Text("Submit some Markdown and see the output.")
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
embedded = isEmbedded
)
) {
submitMdSource?.let { JBMRenderer(it, Modifier) }
?: Text("Submit some Markdown and see the output.")
}
}
}
}

View File

@ -171,4 +171,15 @@ object ClearRippleTheme : RippleTheme {
hoveredAlpha = 0.0f,
pressedAlpha = 0.0f,
)
}
}
fun isThemeDark(theme: Theme, systemIsDark: Boolean): Boolean {
return when (theme) {
Theme.Revolt, Theme.Amoled -> true
Theme.Light -> false
Theme.M3Dynamic, Theme.None -> systemIsDark
}
}
@Composable
fun isThemeDark(theme: Theme) = isThemeDark(theme, isSystemInDarkTheme())