Fixing custom emoji and markdown issues (#5)

This commit is contained in:
Alex Yong 2025-09-03 10:40:56 -04:00 committed by GitHub
parent dc2699e089
commit 406c017696
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 274 additions and 120 deletions

View File

@ -28,13 +28,13 @@ You can download the latest APK [here](https://github.com/alexjyong/android/rele
## Features Added
<table>
<tr>
<td width="50%" valign="top">
<td width="50%" valign="top" align="center">
<strong>Emojis next to channel names</strong><br>
<img src="https://github.com/user-attachments/assets/6c37f059-47ef-4028-aac9-ef233f7a85e4" width="300" alt="Channel Emojis">
</td>
<td width="50%" valign="top">
<td width="50%" valign="top" align="center">
<strong>Jump to replied message</strong><br>
<img src="https://github.com/user-attachments/assets/1dc0fb6b-521d-40e2-a833-1ada19bd207b" width="220" alt="Jump to Reply">
<img src="https://github.com/user-attachments/assets/1dc0fb6b-521d-40e2-a833-1ada19bd207b" width="300" alt="Jump to Reply">
</td>
</tr>
<tr>
@ -43,10 +43,17 @@ You can download the latest APK [here](https://github.com/alexjyong/android/rele
<img src="https://github.com/user-attachments/assets/1450ea4c-a283-4ac3-8a0a-8e9f1adb436b" width="300" alt="Server Context Menu">
</td>
</tr>
<tr>
<td colspan="2" align="center" valign="top">
<strong>Recently used emojis</strong><br>
<img src="https://github.com/user-attachments/assets/6f1e126a-8e73-49ee-a5d1-c4841907e14a" width="600" alt="Recently Used Emojis">
</td>
</tr>
</table>
The codebase includes the app itself, as well as an internal library for interacting with the Revolt
API. The app is written in Kotlin, and wholly
uses [Jetpack Compose](https://developer.android.com/jetpack/compose).

View File

@ -26,7 +26,8 @@ class ExperimentInstance(default: Boolean) {
* - All experiments can be disabled at once with a single toggle.
*/
object Experiments {
val useKotlinBasedMarkdownRenderer = ExperimentInstance(true)
val useKotlinBasedMarkdownRenderer = ExperimentInstance(false)
val useEnhancedMarkdownRenderer = ExperimentInstance(true)
val usePolar = ExperimentInstance(false)
val enableServerIdentityOptions = ExperimentInstance(false)
val useFinalMarkdownRenderer = ExperimentInstance(false)
@ -43,6 +44,9 @@ object Experiments {
useKotlinBasedMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useKotlinBasedMarkdownRenderer") == true
)
useEnhancedMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useEnhancedMarkdownRenderer") == true
)
usePolar.setEnabled(
kvStorage.getBoolean("exp/usePolar") == true
)
@ -53,8 +57,16 @@ object Experiments {
kvStorage.getBoolean("exp/useFinalMarkdownRenderer") == true
)
if (useFinalMarkdownRenderer.isEnabled && useKotlinBasedMarkdownRenderer.isEnabled) {
// if jbm and fm are enabled, fm takes precedence. this should not be possible in practice
if (useFinalMarkdownRenderer.isEnabled) {
if (useEnhancedMarkdownRenderer.isEnabled) {
useEnhancedMarkdownRenderer.setEnabled(false)
kvStorage.set("exp/useEnhancedMarkdownRenderer", false)
}
if (useKotlinBasedMarkdownRenderer.isEnabled) {
useKotlinBasedMarkdownRenderer.setEnabled(false)
kvStorage.set("exp/useKotlinBasedMarkdownRenderer", false)
}
} else if (useEnhancedMarkdownRenderer.isEnabled && useKotlinBasedMarkdownRenderer.isEnabled) {
useKotlinBasedMarkdownRenderer.setEnabled(false)
kvStorage.set("exp/useKotlinBasedMarkdownRenderer", false)
}

View File

@ -0,0 +1,144 @@
package chat.revolt.composables.generic
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.isUnspecified
import chat.revolt.api.REVOLT_FILES
/**
* Text composable that automatically detects and renders custom emojis in the format :ULID:
* This bypasses all parser-level emoji handling and works at the Compose rendering level.
*/
@Composable
fun EmojiAwareText(
text: AnnotatedString,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
inlineContent: Map<String, InlineTextContent> = emptyMap(),
onTextLayout: ((androidx.compose.ui.text.TextLayoutResult) -> Unit)? = null
) {
val fontSize = LocalTextStyle.current.fontSize
val emojiSize = if (fontSize.isUnspecified) 16.sp else fontSize
val customEmojiRegex = Regex(":([0-9A-HJKMNP-TV-Z]{26}):")
val matches = customEmojiRegex.findAll(text.text).toList()
if (matches.isEmpty()) {
Text(
text = text,
modifier = modifier,
maxLines = maxLines,
inlineContent = inlineContent,
onTextLayout = onTextLayout ?: {}
)
return
}
val processedText = buildAnnotatedString {
val sourceText = text.text
var lastIndex = 0
matches.forEach { match ->
val emojiId = match.groupValues[1]
val placeholderKey = "emoji_$emojiId"
if (match.range.first > lastIndex) {
val beforeEmoji = sourceText.substring(lastIndex, match.range.first)
append(beforeEmoji)
text.spanStyles.forEach { spanStyle ->
if (spanStyle.start < match.range.first && spanStyle.end > lastIndex) {
val spanStart = maxOf(spanStyle.start - lastIndex, 0)
val spanEnd = minOf(spanStyle.end - lastIndex, beforeEmoji.length)
if (spanStart < spanEnd) {
addStyle(spanStyle.item, length - beforeEmoji.length + spanStart, length - beforeEmoji.length + spanEnd)
}
}
}
}
appendInlineContent(placeholderKey, "[emoji]")
lastIndex = match.range.last + 1
}
if (lastIndex < sourceText.length) {
val remainingText = sourceText.substring(lastIndex)
append(remainingText)
text.spanStyles.forEach { spanStyle ->
if (spanStyle.start < sourceText.length && spanStyle.end > lastIndex) {
val spanStart = maxOf(spanStyle.start - lastIndex, 0)
val spanEnd = minOf(spanStyle.end - lastIndex, remainingText.length)
if (spanStart < spanEnd) {
addStyle(spanStyle.item, length - remainingText.length + spanStart, length - remainingText.length + spanEnd)
}
}
}
}
}
val emojiInlineContent = matches.associate { match ->
val emojiId = match.groupValues[1]
val placeholderKey = "emoji_$emojiId"
placeholderKey to InlineTextContent(
placeholder = Placeholder(
width = emojiSize,
height = emojiSize,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
RemoteImage(
url = "$REVOLT_FILES/emojis/$emojiId",
description = ":$emojiId:",
modifier = Modifier
.size(emojiSize.value.dp)
.clip(CircleShape)
)
}
}
val mergedInlineContent = inlineContent + emojiInlineContent
Text(
text = processedText,
inlineContent = mergedInlineContent,
modifier = modifier,
maxLines = maxLines,
onTextLayout = onTextLayout ?: {}
)
}
/**
* Overload for plain string input
*/
@Composable
fun EmojiAwareText(
text: String,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
inlineContent: Map<String, InlineTextContent> = emptyMap(),
onTextLayout: ((androidx.compose.ui.text.TextLayoutResult) -> Unit)? = null
) {
EmojiAwareText(
text = AnnotatedString(text),
modifier = modifier,
maxLines = maxLines,
inlineContent = inlineContent,
onTextLayout = onTextLayout
)
}

View File

@ -43,6 +43,7 @@ import chat.revolt.api.routes.custom.fetchEmoji
import chat.revolt.api.schemas.isInviteUri
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.generic.EmojiAwareText
import chat.revolt.composables.generic.RemoteImage
import chat.revolt.composables.utils.detectTapGesturesConditionalConsume
import chat.revolt.internals.EmojiRepository
@ -484,43 +485,9 @@ fun MarkdownText(textNode: AstNode, modifier: Modifier = Modifier) {
false
}
Text(
EmojiAwareText(
text = annotatedText,
style = LocalTextStyle.current,
color = LocalContentColor.current,
onTextLayout = { layoutResult = it },
inlineContent = mapOf(
"CustomEmote" to InlineTextContent(
placeholder = Placeholder(
width = LocalTextStyle.current.fontSize * 1.5,
height = LocalTextStyle.current.fontSize * 1.5,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
),
) { id ->
val emote = RevoltAPI.emojiCache[id]
if (emote == null) {
scope.launch {
try {
RevoltAPI.emojiCache[id] = fetchEmoji(id)
} catch (e: Exception) {
// no-op
}
}
return@InlineTextContent
} else {
with(LocalDensity.current) {
RemoteImage(
url = "$REVOLT_FILES/emojis/${id}",
description = emote.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.width((LocalTextStyle.current.fontSize * 1.5).toDp())
.height((LocalTextStyle.current.fontSize * 1.5).toDp())
)
}
}
}
),
modifier = modifier.pointerInput(onClick, onLongClick) {
detectTapGesturesConditionalConsume(
onTap = { pos ->

View File

@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
import chat.revolt.api.settings.Experiments
import chat.revolt.markdown.jbm.JBM
import chat.revolt.markdown.jbm.JBMRenderer
import chat.revolt.markdown.jbm.JBMEnhancedRenderer
import chat.revolt.ndk.Stendal
@OptIn(JBM::class)
@ -16,10 +17,15 @@ fun RichMarkdown(input: String, modifier: Modifier = Modifier) {
Experiments.useFinalMarkdownRenderer.isEnabled -> {
JBMRenderer(input)
}
else -> {
Experiments.useEnhancedMarkdownRenderer.isEnabled -> {
JBMEnhancedRenderer(input)
}
Experiments.useKotlinBasedMarkdownRenderer.isEnabled -> {
JBMRenderer(input)
}
else -> {
JBMEnhancedRenderer(input)
}
}
}
}

View File

@ -10,7 +10,7 @@ annotation class JBM
@JBM
object JBMApi {
fun parse(src: String): ASTNode {
return MarkdownParser(RSMFlavourDescriptor()).buildMarkdownTreeFromString(src)
fun parse(src: String, flavor: RSMFlavourDescriptor = RSMFlavourDescriptor()): ASTNode {
return MarkdownParser(flavor).buildMarkdownTreeFromString(src)
}
}

View File

@ -0,0 +1,23 @@
package chat.revolt.markdown.jbm
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
/**
* Enhanced JBM Renderer that provides additional markdown features.
* This renderer inherits all functionality from the original JBMRenderer while adding support
* for spoilers and other enhancements through a simple preprocessing approach.
*/
@JBM
@Composable
fun JBMEnhancedRenderer(content: String, modifier: Modifier = Modifier) {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
enhanced = true
)
) {
JBMRenderer(content, modifier)
}
}

View File

@ -83,6 +83,7 @@ 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.composables.generic.EmojiAwareText
import chat.revolt.composables.generic.RemoteImage
import chat.revolt.composables.generic.UserAvatar
import chat.revolt.composables.markdown.Annotations
@ -138,6 +139,7 @@ data class JBMarkdownTreeState(
val currentServer: String? = null,
val embedded: Boolean = false,
val singleLine: Boolean = false,
val enhanced: Boolean = false,
val colors: JBMColors = JBMColors(
clickable = Color(0xFFFF00FF),
clickableBackground = Color(0x2000FF00)
@ -153,11 +155,14 @@ val avatarPadding = 2.dp
@Composable
@JBM
fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
var tree by remember { mutableStateOf(JBMApi.parse(content)) }
val state = LocalJBMarkdownTreeState.current
val flavor = if (state.enhanced) RSMEnhancedFlavourDescriptor() else RSMFlavourDescriptor()
var tree by remember { mutableStateOf(JBMApi.parse(content, flavor)) }
var revealedSpoilers by remember { mutableStateOf(setOf<String>()) }
LaunchedEffect(content) {
tree = JBMApi.parse(content)
LaunchedEffect(content, state.enhanced) {
tree = JBMApi.parse(content, flavor)
}
CompositionLocalProvider(
@ -324,23 +329,6 @@ private fun annotateText(
}
}
RSMElementTypes.CUSTOM_EMOTE -> {
val contents = node.getTextInNode(sourceText).toString()
val emoteId = contents.removeSurrounding(":", ":")
if (emoteId == contents || !emoteId.isUlid()) {
// Invalid custom emote. Append as if it were regular text.
for (child in node.children) {
append(annotateText(state, child, revealedSpoilers))
}
} else {
pushStringAnnotation(
tag = JBMAnnotations.CustomEmote.tag,
annotation = emoteId
)
appendInlineContent(JBMAnnotations.CustomEmote.tag, emoteId)
pop()
}
}
RSMElementTypes.SPOILER -> {
android.util.Log.d("JBMRenderer", "Found SPOILER node with ${node.children.size} children")
@ -714,14 +702,6 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
return@handler true
}
JBMAnnotations.CustomEmote.tag -> {
scope.launch {
ActionChannel.send(
Action.EmoteInfo(item)
)
}
return@handler true
}
JBMAnnotations.Spoiler.tag -> {
mdState.onSpoilerToggle?.invoke(item)
@ -753,11 +733,10 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
return@handler false
}
Text(
EmojiAwareText(
text = annotatedText,
onTextLayout = { layoutResult = it },
maxLines = if (mdState.singleLine) 1 else Int.MAX_VALUE,
overflow = if (mdState.singleLine) TextOverflow.Ellipsis else TextOverflow.Clip,
modifier = modifier.pointerInput(onClick, onLongClick) {
detectTapGesturesConditionalConsume(
onTap = { pos ->
@ -851,36 +830,6 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
}
}
},
JBMAnnotations.CustomEmote.tag to InlineTextContent(
placeholder = Placeholder(
width = LocalTextStyle.current.fontSize * 1.5,
height = LocalTextStyle.current.fontSize * 1.5,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
),
) { id ->
val emote = RevoltAPI.emojiCache[id]
if (emote == null) {
scope.launch {
try {
RevoltAPI.emojiCache[id] = fetchEmoji(id)
} catch (e: Exception) {
// no-op
}
}
return@InlineTextContent
} else {
with(LocalDensity.current) {
RemoteImage(
url = "$REVOLT_FILES/emojis/${id}",
description = emote.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.width((LocalTextStyle.current.fontSize * 1.5).toDp())
.height((LocalTextStyle.current.fontSize * 1.5).toDp())
)
}
}
},
JBMAnnotations.UserAvatar.tag to with(LocalDensity.current) {
val placeholderBaseWidth =
(LocalTextStyle.current.fontSize * 1.5).toPx() - (avatarPadding * 2).toPx()

View File

@ -0,0 +1,20 @@
package chat.revolt.markdown.jbm
import chat.revolt.markdown.jbm.sequentialparsers.SpoilerParser
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.SequentialParserManager
/**
* Enhanced RSM Flavour Descriptor that inherits from the original working RSMFlavourDescriptor.
* This enhanced version adds support for spoilers by carefully adding SpoilerParser
* at the end of the parsing sequence to avoid conflicts with existing parsers.
*/
class RSMEnhancedFlavourDescriptor : RSMFlavourDescriptor() {
override val sequentialParserManager = object : SequentialParserManager() {
override fun getParserSequence(): List<SequentialParser> {
val originalParsers = super@RSMEnhancedFlavourDescriptor.sequentialParserManager.getParserSequence()
return originalParsers + SpoilerParser()
}
}
}

View File

@ -1,10 +1,8 @@
package chat.revolt.markdown.jbm
import chat.revolt.markdown.jbm.sequentialparsers.ChannelMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.CustomEmoteParser
import chat.revolt.markdown.jbm.sequentialparsers.MassMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.RoleMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.SpoilerParser
import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
@ -21,7 +19,7 @@ import org.intellij.markdown.parser.sequentialparsers.impl.InlineLinkParser
import org.intellij.markdown.parser.sequentialparsers.impl.MathParser
import org.intellij.markdown.parser.sequentialparsers.impl.ReferenceLinkParser
class RSMFlavourDescriptor : GFMFlavourDescriptor() {
open class RSMFlavourDescriptor : GFMFlavourDescriptor() {
override val sequentialParserManager = object : SequentialParserManager() {
override fun getParserSequence(): List<SequentialParser> {
return listOf(
@ -35,9 +33,7 @@ class RSMFlavourDescriptor : GFMFlavourDescriptor() {
UserMentionParser(),
ChannelMentionParser(),
RoleMentionParser(),
MassMentionParser(),
CustomEmoteParser(),
SpoilerParser()
MassMentionParser()
)
}
}

View File

@ -16,11 +16,14 @@ class CustomEmoteParser : SequentialParser {
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
android.util.Log.d("CustomEmoteParser", "Processing token: type=${iterator.type}, text='${iterator.toString()}'")
if (iterator.type == MarkdownTokenTypes.COLON) {
android.util.Log.d("CustomEmoteParser", "Found COLON token")
val endIterator = findNextColon(iterator.advance())
if (endIterator != null) {
android.util.Log.d("CustomEmoteParser", "Found matching closing colon, creating CUSTOM_EMOTE node")
result.withNode(
SequentialParser.Node(
iterator.index..endIterator.index + 1,

View File

@ -47,7 +47,7 @@ import chat.revolt.settings.dsl.SubcategoryContentInsets
import kotlinx.coroutines.launch
enum class MarkdownRenderer {
JetBrains, FinalMarkdown
JetBrains, JetBrainsEnhanced, FinalMarkdown
}
class ExperimentsSettingsScreenViewModel : ViewModel() {
@ -56,17 +56,21 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
fun init() {
viewModelScope.launch {
when {
Experiments.useKotlinBasedMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.JetBrains
}
Experiments.useFinalMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.FinalMarkdown
}
else -> {
Experiments.useEnhancedMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.JetBrainsEnhanced
}
Experiments.useKotlinBasedMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.JetBrains
}
else -> {
mdRenderer.value = MarkdownRenderer.JetBrainsEnhanced
}
}
usePolarChecked.value = Experiments.usePolar.isEnabled
enableServerIdentityOptionsChecked.value =
@ -98,7 +102,7 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
}
}
val mdRenderer = mutableStateOf(MarkdownRenderer.JetBrains)
val mdRenderer = mutableStateOf(MarkdownRenderer.JetBrainsEnhanced)
fun setMdRenderer(value: MarkdownRenderer) {
viewModelScope.launch {
@ -106,6 +110,17 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
MarkdownRenderer.JetBrains -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", true)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(true)
kv.set("exp/useEnhancedMarkdownRenderer", false)
Experiments.useEnhancedMarkdownRenderer.setEnabled(false)
kv.set("exp/useFinalMarkdownRenderer", false)
Experiments.useFinalMarkdownRenderer.setEnabled(false)
}
MarkdownRenderer.JetBrainsEnhanced -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
kv.set("exp/useEnhancedMarkdownRenderer", true)
Experiments.useEnhancedMarkdownRenderer.setEnabled(true)
kv.set("exp/useFinalMarkdownRenderer", false)
Experiments.useFinalMarkdownRenderer.setEnabled(false)
}
@ -113,6 +128,8 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
MarkdownRenderer.FinalMarkdown -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
kv.set("exp/useEnhancedMarkdownRenderer", false)
Experiments.useEnhancedMarkdownRenderer.setEnabled(false)
kv.set("exp/useFinalMarkdownRenderer", true)
Experiments.useFinalMarkdownRenderer.setEnabled(true)
}
@ -203,7 +220,8 @@ fun ExperimentsSettingsScreen(
},
supportingContent = {
when (viewModel.mdRenderer.value) {
MarkdownRenderer.JetBrains -> Text("Use the Kotlin-based JetBrains Markdown renderer for messages. This renderer is feature-complete with support for strikethrough, spoilers, and more. (Default)")
MarkdownRenderer.JetBrains -> Text("Use the original Kotlin-based JetBrains Markdown renderer for messages. Stable but without spoiler support.")
MarkdownRenderer.JetBrainsEnhanced -> Text("Use the enhanced JetBrains Markdown renderer with spoiler support and other improvements. Recommended for best experience. (Default)")
MarkdownRenderer.FinalMarkdown -> Text("Use a new blazingly fast markdown renderer for messages. This renderer is experimental and may have missing features.")
}
},
@ -226,6 +244,15 @@ fun ExperimentsSettingsScreen(
) {
Text("JetBrains")
}
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.JetBrainsEnhanced,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.JetBrainsEnhanced) },
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("JetBrains Enhanced")
}
if (FeatureFlags.finalMarkdownGranted || viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown) {
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown,