adding in better emoji handling, a recently used emoji section, and better markdown handling

This commit is contained in:
Alex Yong 2025-08-30 04:39:10 -04:00 committed by GitHub
parent f05d010007
commit ea692b18dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 817 additions and 182 deletions

View File

@ -16,13 +16,16 @@
## Description
NOTE: This is a forked version of the Android app for the [Revolt](https://revolt.chat) chat platform. I made for some QOL changes that aren't present in the current version at the time of writing.
NOTE: This is a forked version of the Android app for the [Revolt](https://revolt.chat) chat platform.
I am not affilated with the Revolt Team, nor is this an official Revolt product.
I made for some QOL changes that aren't present in the current version at the time of writing.
Feel free to use this for whatever, but note that this is NOT the official Revolt android app. :)
You can download the latest APK [here](https://github.com/alexjyong/android/releases/download/1.3.6ba-forked/app-release.apk).
You can download the latest APK [here](https://github.com/alexjyong/android/releases/download/1.3.6bb-forked/app-release.apk).
Or the latest debug APK [here](https://github.com/alexjyong/android/releases/download/1.3.6ba-forked/app-debug.apk).
Or the latest debug APK [here](https://github.com/alexjyong/android/releases/download/1.3.6bb-forked/app-debug.apk).
## Features Added
<table>
@ -122,6 +125,51 @@ Or get the apk [directly from here](https://github.com/psanford/wormhole-william
Or install [Termux](https://termux.dev/en/), then install `wormhole-rs` on Termux with `pkg install magic-wormhole-rs` and fetch the apk with
`wormhole-rs receive YOUR_CODE_HERE`
## Quick Build
If you don't want to download the apks in the releases section and rather build yourself, follow these steps:
Fire up a Github Codespaces instance at this link [here](https://github.com/codespaces/new?hide_repo_select=true&ref=combined-pr&repo=1020437871&skip_quickstart=true&machine=premiumLinux&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsEast)
The URL should have it selected for you automatically, but be sure to use this branch for your instance!
Note that this url will have an 8-core instance selected by default. Feel free to use a smaller instance, but I've ran into build errors with that.
At the time of writing, Github offers a number of free hours for personal accounts, but note that this bigger instance will use more of your free hours than a smaller one. **For just building the apk and downloading it to whatever device, this should be fine though**. Be sure to delete the instance when you are done. **It won't cost you $$ if you don't have payment set up with Github or have budget limits.** [See the billing page for more details.](https://docs.github.com/en/billing/concepts/product-billing/github-codespaces#pricing-for-paid-usage)
After the instance fires up run
```sh
./gradlew assembledebug --no-daemon
```
To generate a debug version of the application.
If you wanted a signed copy that isn't in debug mode, set up a release-key.keystore file, update revoltbuild.properties to have your passwords and run:
```sh
./gradlew assembleRelease -x app:uploadSentryProguardMappingsRelease
```
It will be located in `app/build/outputs/apk/debug/` under the name `app-debug.apk`
Download it to your system by right clicking on the file like so:
<img width="455" height="596" alt="image" src="https://github.com/user-attachments/assets/2fdffb6b-0fdc-4131-97b4-3360ae8871d8" />
Send it to your phone, and install and run it!
Alternatively, you can send it to your phone right from Codespaces using magic wormhole (installed on this codespace instance by default)
`wormhole send app/build/outputs/apk/debug/app-debug.apk`
It will give you a code that you can punch into your phone.
Either use the [Wormhole William app from the Google Playstore](https://play.google.com/store/apps/details?id=io.sanford.wormhole_william&hl=en_US)
Or get the apk [directly from here](https://github.com/psanford/wormhole-william-mobile/releases/tag/v1.0.13)
Or install [Termux](https://termux.dev/en/), then install `wormhole-rs` on Termux with `pkg install magic-wormhole-rs` and fetch the apk with
`wormhole-rs receive YOUR_CODE_HERE`
## Quick Start
Open the project in Android Studio. You can then run the app on an emulator or a physical device by

View File

@ -80,7 +80,7 @@ android {
minSdk = 24
targetSdk = 36
versionCode = Integer.parseInt("001_003_206".replace("_", ""), 10)
versionName = "1.3.6ba-forked"
versionName = "1.3.6bb-forked"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -2,6 +2,7 @@
#include <jni.h>
#include <string>
#include <vector>
#include <regex>
#include <cmark.h>
#define STENDAL_ASTNODE_CONSTRUCTOR_SIGNATURE "(ILjava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;)V"
@ -26,6 +27,15 @@ namespace Stendal {
return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin());
}
std::string preprocess_markdown(const std::string& input) {
std::string result = input;
std::regex spoiler_regex(R"(\|\|(.+?)\|\|)");
result = std::regex_replace(result, spoiler_regex, "<spoiler>$1</spoiler>");
return result;
}
void init(JNIEnv *env) {
jclass localArrayListClass = env->FindClass("java/util/ArrayList");
arrayListClass = (jclass) env->NewGlobalRef(localArrayListClass);
@ -148,7 +158,10 @@ Java_chat_revolt_ndk_Stendal_init(JNIEnv *env, [[maybe_unused]] jobject thiz) {
extern "C" JNIEXPORT jobject JNICALL
Java_chat_revolt_ndk_Stendal_render(JNIEnv *env, [[maybe_unused]] jobject thiz, jstring input) {
const char *inputStr = env->GetStringUTFChars(input, nullptr);
cmark_node *doc = cmark_parse_document(inputStr, strlen(inputStr),
std::string preprocessed = Stendal::preprocess_markdown(std::string(inputStr));
cmark_node *doc = cmark_parse_document(preprocessed.c_str(), preprocessed.length(),
CMARK_OPT_DEFAULT | CMARK_OPT_HARDBREAKS |
CMARK_OPT_VALIDATE_UTF8);

View File

@ -2,8 +2,12 @@ package chat.revolt
import android.app.Application
import android.os.StrictMode
import chat.revolt.internals.EmojiRepository
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import logcat.AndroidLogcatLogger
import logcat.LogPriority
@ -13,6 +17,8 @@ class RevoltApplication : Application() {
lateinit var instance: RevoltApplication
}
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onCreate() {
super.onCreate()
AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE)
@ -31,6 +37,8 @@ class RevoltApplication : Application() {
.build()
)
}
EmojiRepository.initialize(applicationScope)
}
init {

View File

@ -18,6 +18,7 @@ import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.Server
import chat.revolt.api.schemas.User
import chat.revolt.api.unreads.Unreads
import chat.revolt.internals.EmojiRepository
import chat.revolt.persistence.Database
import chat.revolt.persistence.SqlStorage
import com.chuckerteam.chucker.api.ChuckerCollector
@ -263,6 +264,8 @@ object RevoltAPI {
channelCache.clear()
emojiCache.clear()
messageCache.clear()
EmojiRepository.invalidateCache()
members.clear()
unreads.clear()

View File

@ -7,6 +7,7 @@ import chat.revolt.api.REVOLT_WEBSOCKET
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.internals.EmojiRepository
import chat.revolt.api.realtime.frames.receivable.AnyFrame
import chat.revolt.api.realtime.frames.receivable.BulkFrame
import chat.revolt.api.realtime.frames.receivable.ChannelAckFrame
@ -243,6 +244,8 @@ object RealtimeSocket {
Log.d("RealtimeSocket", "Adding emojis to cache.")
val emojiMap = readyFrame.emojis.associateBy { it.id!! }
RevoltAPI.emojiCache.putAll(emojiMap)
EmojiRepository.invalidateCache()
Log.d("RealtimeSocket", "Registering push notification channels.")
channelRegistrator.register()

View File

@ -26,7 +26,7 @@ class ExperimentInstance(default: Boolean) {
* - All experiments can be disabled at once with a single toggle.
*/
object Experiments {
val useKotlinBasedMarkdownRenderer = ExperimentInstance(false)
val useKotlinBasedMarkdownRenderer = ExperimentInstance(true)
val usePolar = ExperimentInstance(false)
val enableServerIdentityOptions = ExperimentInstance(false)
val useFinalMarkdownRenderer = ExperimentInstance(false)

View File

@ -432,36 +432,19 @@ fun Message(
message.content?.let {
if (message.content.isBlank()) return@let // if only an attachment is sent
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
currentServer = RevoltAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
message.content
)
.let {
if (it) 2f else 1f
}
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
currentServer = RevoltAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
message.content
)
) {
Spacer(modifier = Modifier.height(2.dp))
JBMRenderer(message.content)
}
} else {
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)
}
.let {
if (it) 2f else 1f
}
)
) {
Spacer(modifier = Modifier.height(2.dp))
RichMarkdown(input = message.content)
}
}
}

View File

@ -79,10 +79,12 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Member
@ -139,6 +141,15 @@ sealed class AutocompleteSuggestion {
) : AutocompleteSuggestion()
}
private fun makeTextReadable(text: String): String {
val customEmojiRegex = Regex(":([0-9A-Z]{26}):")
return customEmojiRegex.replace(text) { matchResult ->
val emojiId = matchResult.groupValues[1]
val emojiName = RevoltAPI.emojiCache[emojiId]?.name
if (emojiName != null) ":$emojiName:" else matchResult.value
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageField(
@ -203,6 +214,7 @@ fun MessageField(
}
val scope = rememberCoroutineScope()
var isFocused by remember { mutableStateOf(false) }
LaunchedEffect(textFieldState.text) {
onValueChange(textFieldState.text.toString())
@ -536,6 +548,7 @@ fun MessageField(
.heightIn(max = 128.dp)
.verticalScroll(rememberScrollState())
.onFocusChanged {
isFocused = it.isFocused
onFocusChange(it.isFocused)
}
.focusRequester(focusRequester)
@ -575,8 +588,20 @@ fun MessageField(
),
modifier = Modifier.align(Alignment.CenterStart)
)
} else if (!isFocused) {
Text(
text = makeTextReadable(textFieldState.text.toString()),
style = LocalTextStyle.current.copy(
color = if (failedValidation) {
MaterialTheme.colorScheme.error
} else LocalContentColor.current
),
modifier = Modifier.align(Alignment.CenterStart)
)
}
if (isFocused || textFieldState.text.isEmptyOrOnlyNewlines()) {
innerTextField()
}
innerTextField()
}
}
)

View File

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
@ -30,6 +31,7 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
@ -68,10 +70,12 @@ import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.generic.IconPlaceholder
import chat.revolt.composables.generic.RemoteImage
import chat.revolt.internals.Category
import chat.revolt.internals.EmojiImpl
import chat.revolt.internals.EmojiRepository
import chat.revolt.internals.EmojiPickerItem
import chat.revolt.internals.EmojiUsageTracker
import chat.revolt.internals.FitzpatrickSkinTone
import chat.revolt.internals.UnicodeEmojiSection
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@ -82,17 +86,28 @@ fun EmojiPicker(
) {
val view = LocalView.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val emojiImpl = remember { EmojiImpl() }
val pickerList = remember(emojiImpl) { emojiImpl.flatPickerList() }
val servers = remember(emojiImpl) { emojiImpl.serversWithEmotes() }
val categorySpans = remember(pickerList) { emojiImpl.categorySpans(pickerList) }
LaunchedEffect(Unit) {
EmojiRepository.initialize(scope)
}
val isReady by EmojiRepository.isReady
val isLoading by EmojiRepository.isLoading
val pickerList = remember(isReady) {
if (isReady) EmojiRepository.flatPickerList() else emptyList()
}
val servers = remember(isReady) {
if (isReady) EmojiRepository.serversWithEmotes() else emptyList()
}
val categorySpans = remember(pickerList) {
if (isReady && pickerList.isNotEmpty()) EmojiRepository.categorySpans(pickerList) else emptyMap()
}
val gridState = rememberLazyGridState()
val categoryRowScrollState = rememberScrollState()
val scope = rememberCoroutineScope()
val spanCount = 9 // https://github.com/googlefonts/emoji-metadata/#readme
// The current category is the one that the user is currently looking at.
@ -144,7 +159,7 @@ fun EmojiPicker(
val skinSample = remember(pickerList) {
pickerList
.filterIsInstance<EmojiPickerItem.UnicodeEmoji>()
.first { it.character == "\uD83E\uDEF0" }
.firstOrNull { it.character == "\uD83E\uDEF0" }
}
var searchQuery by remember { mutableStateOf("") }
@ -155,11 +170,28 @@ fun EmojiPicker(
)
val searchResults = remember { mutableStateListOf<EmojiPickerItem>() }
LaunchedEffect(searchQuery) {
var isSearching by remember { mutableStateOf(false) }
LaunchedEffect(searchQuery, isReady) {
searchResults.clear()
if (searchQuery.isBlank()) return@LaunchedEffect
searchResults.addAll(emojiImpl.searchForEmoji(searchQuery))
gridState.scrollToItem(0)
if (searchQuery.isBlank() || !isReady) {
isSearching = false
return@LaunchedEffect
}
isSearching = true
delay(300)
try {
val results = EmojiRepository.searchForEmoji(searchQuery)
searchResults.clear()
searchResults.addAll(results)
if (results.isNotEmpty()) {
gridState.scrollToItem(0)
}
} finally {
isSearching = false
}
}
val onServerEmoteInfo: (String) -> Unit = {
@ -175,14 +207,16 @@ fun EmojiPicker(
val onEmojiClick: (EmojiPickerItem) -> Unit = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
when (it) {
is EmojiPickerItem.UnicodeEmoji -> onEmojiSelected(
emojiImpl.applyFitzpatrickSkinTone(
it,
currentSkinTone
)
)
is EmojiPickerItem.ServerEmote -> onEmojiSelected(":${it.emote.id}:")
is EmojiPickerItem.UnicodeEmoji -> {
val emojiChar = EmojiRepository.applyFitzpatrickSkinTone(it, currentSkinTone)
EmojiUsageTracker.recordUsage(emojiChar)
onEmojiSelected(emojiChar)
}
is EmojiPickerItem.ServerEmote -> {
val emojiCode = ":${it.emote.id}:"
EmojiUsageTracker.recordUsage(emojiCode)
onEmojiSelected(emojiCode)
}
else -> {}
}
}
@ -195,7 +229,28 @@ fun EmojiPicker(
Column(
modifier = Modifier
.fillMaxSize()
.heightIn(min = 400.dp)
) {
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
CircularProgressIndicator()
Text(
text = stringResource(R.string.loading),
style = MaterialTheme.typography.bodyMedium
)
}
}
return
}
Box(
modifier = Modifier
.fillMaxWidth()
@ -268,10 +323,12 @@ fun EmojiPicker(
) {
FitzpatrickSkinTone.entries.forEach { skinTone ->
Text(
emojiImpl.applyFitzpatrickSkinTone(
skinSample,
skinTone
),
skinSample?.let { sample ->
EmojiRepository.applyFitzpatrickSkinTone(
sample,
skinTone
)
} ?: "",
modifier = Modifier
.padding(4.dp)
.requiredSize(24.dp)
@ -327,10 +384,12 @@ fun EmojiPicker(
.aspectRatio(1f)
) {
Text(
emojiImpl.applyFitzpatrickSkinTone(
skinSample,
currentSkinTone
),
skinSample?.let { sample ->
EmojiRepository.applyFitzpatrickSkinTone(
sample,
currentSkinTone
)
} ?: "",
modifier = Modifier
.padding(4.dp)
.requiredSize(24.dp)
@ -354,6 +413,66 @@ fun EmojiPicker(
Spacer(Modifier.height(4.dp))
val recentEmojis = remember(isReady) {
if (isReady) EmojiUsageTracker.getRecentlyUsed() else emptyList()
}
AnimatedVisibility(searchResults.isEmpty() && recentEmojis.isNotEmpty()) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Recently Used",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)
)
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
recentEmojis.forEach { emoji ->
if (emoji.matches(Regex(":[0-9A-Z]{26}:"))) {
val emojiId = emoji.removeSurrounding(":", ":")
RemoteImage(
url = "$REVOLT_FILES/emojis/$emojiId",
description = emoji,
modifier = Modifier
.clip(CircleShape)
.clickable {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
EmojiUsageTracker.recordUsage(emoji)
onEmojiSelected(emoji)
}
.padding(4.dp)
.size(24.dp)
)
} else {
Text(
text = emoji,
fontSize = 24.sp,
modifier = Modifier
.clip(CircleShape)
.clickable {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
EmojiUsageTracker.recordUsage(emoji)
onEmojiSelected(emoji)
}
.padding(4.dp)
.size(24.dp),
textAlign = TextAlign.Center
)
}
}
}
Spacer(Modifier.height(8.dp))
}
}
AnimatedVisibility(searchResults.isEmpty()) {
Row(
modifier = Modifier
@ -513,9 +632,16 @@ fun EmojiPicker(
}
}
// Search results do not get a key, this is intentional.
items(
searchResults.size,
key = { index ->
val item = searchResults[index]
when (item) {
is EmojiPickerItem.UnicodeEmoji -> "search_unicode_${item.character}"
is EmojiPickerItem.ServerEmote -> "search_server_${item.emote.id}"
is EmojiPickerItem.Section -> "search_section_${item.category.hashCode()}"
}
},
span = {
val item = searchResults[it]
when (item) {
@ -527,7 +653,7 @@ fun EmojiPicker(
) { index ->
PickerItem(
item = searchResults[index],
skinToneFactory = { emojiImpl.applyFitzpatrickSkinTone(it, currentSkinTone) },
skinToneFactory = { EmojiRepository.applyFitzpatrickSkinTone(it, currentSkinTone) },
onClick = onEmojiClick,
onServerEmoteInfo = onServerEmoteInfo,
lesserHeaders = true
@ -547,6 +673,14 @@ fun EmojiPicker(
items(
pickerList.size,
key = { index ->
val item = pickerList[index]
when (item) {
is EmojiPickerItem.UnicodeEmoji -> "unicode_${item.character}"
is EmojiPickerItem.ServerEmote -> "server_${item.emote.id}"
is EmojiPickerItem.Section -> "section_${item.category.hashCode()}"
}
},
span = {
val item = pickerList[it]
when (item) {
@ -558,7 +692,7 @@ fun EmojiPicker(
) { index ->
PickerItem(
item = pickerList[index],
skinToneFactory = { emojiImpl.applyFitzpatrickSkinTone(it, currentSkinTone) },
skinToneFactory = { EmojiRepository.applyFitzpatrickSkinTone(it, currentSkinTone) },
onClick = onEmojiClick,
onServerEmoteInfo = onServerEmoteInfo
)

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.Color
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -32,6 +33,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontSynthesis
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.core.net.toUri
import chat.revolt.R
import chat.revolt.activities.InviteActivity
@ -43,6 +45,7 @@ import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.generic.RemoteImage
import chat.revolt.composables.utils.detectTapGesturesConditionalConsume
import chat.revolt.internals.EmojiRepository
import chat.revolt.internals.resolveTimestamp
import chat.revolt.ndk.AstNode
import chat.revolt.ui.theme.FragmentMono
@ -54,6 +57,7 @@ enum class Annotations(val tag: String, val clickable: Boolean) {
UserMention("UserMention", true),
ChannelMention("ChannelMention", true),
CustomEmote("CustomEmote", true),
Spoiler("Spoiler", true),
Timestamp("Timestamp", false)
}
@ -61,6 +65,8 @@ object MarkdownTextRegularExpressions {
val Mention = Regex("<@([0-9A-Z]{26})>")
val Channel = Regex("<#([0-9A-Z]{26})>")
val CustomEmote = Regex(":([0-9A-Z]{26}):")
val UnicodeEmote = Regex(":([a-zA-Z0-9_+-]+):")
val Spoiler = Regex("\\|\\|(.+?)\\|\\|")
val Timestamp = Regex("<t:([0-9]+?)(:[tTDfFR])?>")
val UrlFallback =
Regex("<?https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)>?")
@ -70,15 +76,21 @@ object MarkdownTextRegularExpressions {
* Visit the AST and its children and return an [AnnotatedString] with the appropriate annotations.
*/
@Composable
fun annotateText(node: AstNode): AnnotatedString {
fun annotateText(node: AstNode, revealedSpoilers: Set<String> = emptySet()): AnnotatedString {
return buildAnnotatedString {
when (node.stringType) {
"text" -> {
val text = node.text ?: ""
var text = node.text ?: ""
text = MarkdownTextRegularExpressions.UnicodeEmote.replace(text) { matchResult ->
val shortcodeName = matchResult.groupValues[1]
EmojiRepository.unicodeByShortcode(shortcodeName) ?: matchResult.value
}
val mentions = MarkdownTextRegularExpressions.Mention.findAll(text)
val channels = MarkdownTextRegularExpressions.Channel.findAll(text)
val customEmotes = MarkdownTextRegularExpressions.CustomEmote.findAll(text)
val spoilers = MarkdownTextRegularExpressions.Spoiler.findAll(text)
val timestamps = MarkdownTextRegularExpressions.Timestamp.findAll(text)
val urls = MarkdownTextRegularExpressions.UrlFallback.findAll(text)
@ -153,6 +165,32 @@ fun annotateText(node: AstNode): AnnotatedString {
lastIndex = emote.range.last + 1
}
for (spoiler in spoilers) {
try {
append(text.substring(lastIndex, spoiler.range.first))
} catch (e: Exception) {
// no-op
}
val spoilerContent = spoiler.groupValues[1]
val spoilerId = "spoiler_${spoiler.range.first}_${spoilerContent.hashCode()}"
val isRevealed = revealedSpoilers.contains(spoilerId)
pushStringAnnotation(
tag = Annotations.Spoiler.tag,
annotation = spoilerId
)
pushStyle(
LocalTextStyle.current.toSpanStyle().copy(
background = if (isRevealed) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.onSurface,
color = if (isRevealed) LocalContentColor.current else MaterialTheme.colorScheme.onSurface
)
)
append(spoilerContent)
pop()
pop()
lastIndex = spoiler.range.last + 1
}
for (timestamp in timestamps) {
try {
append(text.substring(lastIndex, timestamp.range.first))
@ -219,7 +257,7 @@ fun annotateText(node: AstNode): AnnotatedString {
fontSynthesis = FontSynthesis.All
)
)
node.children?.forEach { append(annotateText(it)) }
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
pop()
}
@ -231,7 +269,19 @@ fun annotateText(node: AstNode): AnnotatedString {
fontSynthesis = FontSynthesis.All
)
)
node.children?.forEach { append(annotateText(it)) }
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
pop()
}
"del" -> {
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
textDecoration = TextDecoration.LineThrough,
fontSynthesis = FontSynthesis.All
)
)
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
pop()
}
@ -246,7 +296,7 @@ fun annotateText(node: AstNode): AnnotatedString {
color = MaterialTheme.colorScheme.primary
)
)
node.children?.forEach { append(annotateText(it)) }
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
pop()
pop()
}
@ -264,12 +314,23 @@ fun annotateText(node: AstNode): AnnotatedString {
pop()
}
"spoiler" -> {
pushStyle(
LocalTextStyle.current.toSpanStyle().copy(
background = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface
)
)
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
pop()
}
"softbreak" -> {
append("\n")
}
else -> {
node.children?.forEach { append(annotateText(it)) }
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
}
}
}
@ -278,7 +339,8 @@ fun annotateText(node: AstNode): AnnotatedString {
@Composable
fun MarkdownText(textNode: AstNode, modifier: Modifier = Modifier) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val annotatedText = annotateText(textNode)
var revealedSpoilers by remember { mutableStateOf(setOf<String>()) }
val annotatedText = annotateText(textNode, revealedSpoilers)
val context = LocalContext.current
val background = MaterialTheme.colorScheme.background
val scope = rememberCoroutineScope()
@ -385,6 +447,20 @@ fun MarkdownText(textNode: AstNode, modifier: Modifier = Modifier) {
return@handler true
}
annotatedText.getStringAnnotations(
tag = Annotations.Spoiler.tag,
start = offset,
end = offset
).firstOrNull()?.let { annotation ->
val spoilerId = annotation.item
revealedSpoilers = if (revealedSpoilers.contains(spoilerId)) {
revealedSpoilers - spoilerId
} else {
revealedSpoilers + spoilerId
}
return@handler true
}
}
false

View File

@ -1,6 +1,7 @@
package chat.revolt.composables.markdown
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
@ -10,11 +11,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
@ -127,8 +134,35 @@ fun MarkdownTree(node: AstNode) {
}
}
"spoiler" -> {
SpoilerText(node)
}
else -> {
Children(node)
}
}
}
@Composable
fun SpoilerText(node: AstNode) {
var isRevealed by remember { mutableStateOf(false) }
if (isRevealed) {
MarkdownText(node)
} else {
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f))
.clickable { isRevealed = true }
.padding(horizontal = 2.dp)
) {
Text(
text = "".repeat((node.text?.length ?: 7).coerceAtMost(20)),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
style = LocalTextStyle.current
)
}
}
}

View File

@ -12,10 +12,14 @@ import chat.revolt.ndk.Stendal
@Composable
fun RichMarkdown(input: String, modifier: Modifier = Modifier) {
Column(modifier) {
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
JBMRenderer(input)
} else {
MarkdownTree(node = Stendal.render(input))
when {
Experiments.useFinalMarkdownRenderer.isEnabled -> {
JBMRenderer(input)
}
else -> {
JBMRenderer(input)
}
}
}
}

View File

@ -1,13 +1,19 @@
package chat.revolt.internals
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import chat.revolt.R
import chat.revolt.RevoltApplication
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.Server
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import java.util.concurrent.ConcurrentHashMap
@Serializable
data class Emoji(
@ -61,48 +67,86 @@ sealed class EmojiPickerItem {
data class ServerEmote(val emote: chat.revolt.api.schemas.Emoji) : EmojiPickerItem()
}
class EmojiImpl {
private var metadata: List<EmojiGroup>
private fun initMetadata(context: Context): List<EmojiGroup> {
val json = context.assets.open("metadata/emoji.json").use {
it.reader().readText()
object EmojiRepository {
private var metadata: List<EmojiGroup>? = null
private val serverEmojiCache = ConcurrentHashMap<String, List<EmojiPickerItem>>()
private val serverListCache = ConcurrentHashMap<Int, List<Server>>()
private var cachedPickerList: List<EmojiPickerItem>? = null
private var cachedCategorySpans: Map<Category, Pair<Int, Int>>? = null
val isReady = mutableStateOf(false)
val isLoading = mutableStateOf(false)
private suspend fun initMetadata(context: Context): List<EmojiGroup> {
return withContext(Dispatchers.IO) {
val json = context.assets.open("metadata/emoji.json").use {
it.reader().readText()
}
RevoltJson.decodeFromString(ListSerializer(EmojiGroup.serializer()), json)
}
}
fun initialize(scope: CoroutineScope) {
if (isReady.value || isLoading.value) return
scope.launch {
isLoading.value = true
try {
metadata = initMetadata(RevoltApplication.instance.applicationContext)
isReady.value = true
} catch (e: Exception) {
e.printStackTrace()
} finally {
isLoading.value = false
}
}
return RevoltJson.decodeFromString(ListSerializer(EmojiGroup.serializer()), json)
}
fun serversWithEmotes(): List<Server> {
return RevoltAPI
.emojiCache
.values
.asSequence()
.map { it.parent }
.filterNotNull()
.filter { it.type == "Server" }
.map { it.id }
.distinct()
.mapNotNull { RevoltAPI.serverCache[it] }
.toList()
val cacheKey = RevoltAPI.emojiCache.values.size
return serverListCache.getOrPut(cacheKey) {
RevoltAPI
.emojiCache
.values
.asSequence()
.map { it.parent }
.filterNotNull()
.filter { it.type == "Server" }
.map { it.id }
.distinct()
.mapNotNull { RevoltAPI.serverCache[it] }
.toList()
}
}
fun serverEmoteList(server: Server): List<EmojiPickerItem> {
val list = mutableListOf<EmojiPickerItem>()
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
val cacheKey = "${server.id}_${RevoltAPI.emojiCache.values.size}"
return serverEmojiCache.getOrPut(cacheKey) {
val list = mutableListOf<EmojiPickerItem>()
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
list.add(EmojiPickerItem.Section(Category.ServerEmoteCategory(server)))
list.addAll(emotes.map { EmojiPickerItem.ServerEmote(it) })
list.add(EmojiPickerItem.Section(Category.ServerEmoteCategory(server)))
list.addAll(emotes.map { EmojiPickerItem.ServerEmote(it) })
return list
list
}
}
fun flatPickerList(): List<EmojiPickerItem> {
val currentMetadata = metadata ?: return emptyList()
val cacheKey = "${serversWithEmotes().size}_${RevoltAPI.emojiCache.values.size}"
cachedPickerList?.let { cached ->
if (cacheKey == lastCacheKey) return cached
}
val list = mutableListOf<EmojiPickerItem>()
for (server in serversWithEmotes()) {
list.addAll(serverEmoteList(server))
}
for (group in metadata) {
for (group in currentMetadata) {
val category =
UnicodeEmojiSection.entries.find { it.googleName == group.group } ?: continue
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
@ -121,26 +165,21 @@ class EmojiImpl {
)
}
cachedPickerList = list
lastCacheKey = cacheKey
return list
}
private var lastCacheKey: String? = null
/**
* Returns a map of category to start and end index of the category in the flat picker list
* Impl
* ====
* 1. Iterate through servers that have emotes. Get the index of the server emote category.
* 2. Get all emotes in that server. Add the size of that list to the index of the server emote category.
* 3. Push Pair(index, index + size) to the map.
* 4. Iterate through all unicode emoji categories. Get the index of the category.
* Unless it's the last category {
* 5.1. Get the index of the next category. Subtract 1 from that index.
* 5.2. Push Pair(index, lastIndex) to the map.
* } Otherwise {
* 5. Push Pair(index, Int.MAX_VALUE) to the map.
* }
* 6. Return the map.
*/
fun categorySpans(flatPickerList: List<EmojiPickerItem>): Map<Category, Pair<Int, Int>> {
cachedCategorySpans?.let { cached ->
if (lastCacheKey != null) return cached
}
val output = mutableMapOf<Category, Pair<Int, Int>>()
for (server in serversWithEmotes()) {
@ -170,6 +209,7 @@ class EmojiImpl {
output[Category.UnicodeEmojiCategory(section)] = Pair(index, lastIndex)
}
cachedCategorySpans = output
return output
}
@ -203,6 +243,9 @@ class EmojiImpl {
* query.
*/
fun searchForEmoji(query: String): List<EmojiPickerItem> {
val currentMetadata = metadata ?: return emptyList()
if (query.isBlank()) return emptyList()
val list = mutableListOf<EmojiPickerItem>()
for (server in serversWithEmotes()) {
@ -215,7 +258,7 @@ class EmojiImpl {
}
}
for (group in metadata) {
for (group in currentMetadata) {
val matchingEmoji = group.emoji.filter {
it.shortcodes.any { code ->
code.contains(
@ -248,7 +291,8 @@ class EmojiImpl {
}
fun unicodeByShortcode(shortcode: String): String? {
return metadata.asSequence().mapNotNull { group ->
val currentMetadata = metadata ?: return null
return currentMetadata.asSequence().mapNotNull { group ->
group.emoji.find { emoji ->
emoji.shortcodes.any { code ->
code == ":${shortcode}:"
@ -260,7 +304,8 @@ class EmojiImpl {
}
fun shortcodeContains(query: String): List<Emoji> {
return metadata.asSequence().map { group ->
val currentMetadata = metadata ?: return emptyList()
return currentMetadata.asSequence().map { group ->
group.emoji.filter { emoji ->
emoji.shortcodes.any { code ->
code.contains(query, ignoreCase = true)
@ -270,7 +315,8 @@ class EmojiImpl {
}
fun unicodeAsShortcode(unicode: String): String? {
return metadata.asSequence().mapNotNull { group ->
val currentMetadata = metadata ?: return null
return currentMetadata.asSequence().mapNotNull { group ->
group.emoji.find { emoji ->
emoji.base.joinToString("") { String(Character.toChars(it.toInt())) } == unicode
}
@ -280,7 +326,8 @@ class EmojiImpl {
}
fun codepointIsEmoji(codepoint: Int): Boolean {
return metadata.any { group ->
val currentMetadata = metadata ?: return false
return currentMetadata.any { group ->
group.emoji.any { emoji ->
emoji.base.contains(codepoint.toLong()) || emoji.alternates.any { alternate ->
alternate.contains(codepoint.toLong())
@ -288,8 +335,14 @@ class EmojiImpl {
}
}
}
init {
metadata = initMetadata(RevoltApplication.instance.applicationContext)
fun invalidateCache() {
serverEmojiCache.clear()
serverListCache.clear()
cachedPickerList = null
cachedCategorySpans = null
lastCacheKey = null
}
}
fun EmojiImpl(): EmojiRepository = EmojiRepository

View File

@ -0,0 +1,82 @@
package chat.revolt.internals
import android.content.Context
import chat.revolt.RevoltApplication
import chat.revolt.api.RevoltJson
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@Serializable
data class EmojiUsageData(
val usageCount: Map<String, Int> = emptyMap(),
val lastUsed: Map<String, Long> = emptyMap()
)
object EmojiUsageTracker {
private const val PREFS_NAME = "emoji_usage"
private const val KEY_DATA = "usage_data"
private var usageCount = mutableMapOf<String, Int>()
private var lastUsed = mutableMapOf<String, Long>()
init {
loadFromStorage()
}
fun recordUsage(emoji: String) {
usageCount[emoji] = (usageCount[emoji] ?: 0) + 1
lastUsed[emoji] = System.currentTimeMillis()
saveToStorage()
}
fun getRecentlyUsed(limit: Int = 12): List<String> {
if (lastUsed.isEmpty()) return emptyList()
return lastUsed.entries
.sortedByDescending { it.value }
.take(limit)
.map { it.key }
}
fun getMostUsed(limit: Int = 12): List<String> {
if (usageCount.isEmpty()) return emptyList()
return usageCount.entries
.sortedWith(
compareByDescending<Map.Entry<String, Int>> { it.value }
.thenByDescending { lastUsed[it.key] ?: 0 }
)
.take(limit)
.map { it.key }
}
private fun loadFromStorage() {
try {
val prefs = RevoltApplication.instance.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val jsonData = prefs.getString(KEY_DATA, null) ?: return
val data = RevoltJson.decodeFromString<EmojiUsageData>(jsonData)
usageCount.putAll(data.usageCount)
lastUsed.putAll(data.lastUsed)
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun saveToStorage() {
try {
val data = EmojiUsageData(
usageCount = usageCount.toMap(),
lastUsed = lastUsed.toMap()
)
val jsonData = RevoltJson.encodeToString(data)
val prefs = RevoltApplication.instance.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit()
.putString(KEY_DATA, jsonData)
.apply()
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@ -87,6 +87,7 @@ import chat.revolt.composables.generic.RemoteImage
import chat.revolt.composables.generic.UserAvatar
import chat.revolt.composables.markdown.Annotations
import chat.revolt.composables.utils.detectTapGesturesConditionalConsume
import chat.revolt.markdown.jbm.RSMElementTypes
import chat.revolt.ui.theme.FragmentMono
import chat.revolt.ui.theme.isThemeDark
import dev.snipme.highlights.Highlights
@ -107,6 +108,7 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) {
URL("URL", true),
UserMention("UserMention", true),
ChannelMention("ChannelMention", true),
Spoiler("Spoiler", true),
RoleMention("RoleMention", false),
CustomEmote("CustomEmote", true),
Timestamp("Timestamp", false),
@ -130,6 +132,8 @@ data class JBMarkdownTreeState(
val sourceText: String = "",
val listDepth: Int = 0,
val fontSizeMultiplier: Float = 1f,
val revealedSpoilers: Set<String> = emptySet(),
val onSpoilerToggle: ((String) -> Unit)? = null,
val linksClickable: Boolean = true,
val currentServer: String? = null,
val embedded: Boolean = false,
@ -150,6 +154,7 @@ val avatarPadding = 2.dp
@JBM
fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
var tree by remember { mutableStateOf(JBMApi.parse(content)) }
var revealedSpoilers by remember { mutableStateOf(setOf<String>()) }
LaunchedEffect(content) {
tree = JBMApi.parse(content)
@ -158,6 +163,14 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
sourceText = content,
revealedSpoilers = revealedSpoilers,
onSpoilerToggle = { spoilerId ->
revealedSpoilers = if (revealedSpoilers.contains(spoilerId)) {
revealedSpoilers - spoilerId
} else {
revealedSpoilers + spoilerId
}
},
colors = JBMColors(
clickable = MaterialTheme.colorScheme.primary,
clickableBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
@ -183,14 +196,22 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
}
}
@Composable
private fun annotateText(
state: JBMarkdownTreeState,
node: ASTNode
node: ASTNode,
revealedSpoilers: Set<String> = emptySet()
): AnnotatedString {
val surfaceContainer = MaterialTheme.colorScheme.surfaceContainer
val onSurface = MaterialTheme.colorScheme.onSurface
val contentColor = LocalContentColor.current
val sourceText = state.sourceText
return try {
buildAnnotatedString {
return buildAnnotatedString {
if (node.type.toString().contains("SPOILER", ignoreCase = true) ||
sourceText.contains("||")) {
android.util.Log.d("JBMRenderer", "Processing node: ${node.type}, text contains spoilers: ${sourceText.contains("||")}")
}
when (node.type) {
MarkdownTokenTypes.TEXT -> {
val source = if (state.embedded) {
@ -207,7 +228,7 @@ private fun annotateText(
if (userId == contents || !userId.isUlid()) {
// Invalid user mention. Append as if it were regular text.
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
} else {
pushStringAnnotation(
@ -238,7 +259,7 @@ private fun annotateText(
if (channelId == contents || !channelId.isUlid()) {
// Invalid channel mention. Append as if it were regular text.
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
} else {
pushStringAnnotation(
@ -265,7 +286,7 @@ private fun annotateText(
if (roleId == contents || !roleId.isUlid() || state.currentServer == null) {
// Invalid role mention. Append as if it were regular text.
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
} else {
val server = RevoltAPI.serverCache[state.currentServer]
@ -309,7 +330,7 @@ private fun annotateText(
if (emoteId == contents || !emoteId.isUlid()) {
// Invalid custom emote. Append as if it were regular text.
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
} else {
pushStringAnnotation(
@ -321,6 +342,30 @@ private fun annotateText(
}
}
RSMElementTypes.SPOILER -> {
android.util.Log.d("JBMRenderer", "Found SPOILER node with ${node.children.size} children")
val spoilerContent = node.children.joinToString("") { it.getTextInNode(sourceText).toString() }
val spoilerId = "spoiler_${System.identityHashCode(node)}"
val isRevealed = revealedSpoilers.contains(spoilerId)
android.util.Log.d("JBMRenderer", "Spoiler content: '$spoilerContent', ID: $spoilerId, revealed: $isRevealed")
pushStringAnnotation(
tag = JBMAnnotations.Spoiler.tag,
annotation = spoilerId
)
withStyle(
SpanStyle(
background = if (isRevealed) surfaceContainer else onSurface,
color = if (isRevealed) contentColor else onSurface
)
) {
for (child in node.children) {
append(annotateText(state, child, revealedSpoilers))
}
}
pop()
}
MarkdownTokenTypes.ATX_HEADER -> {
// Do not need to do anything
}
@ -332,7 +377,7 @@ private fun annotateText(
MarkdownElementTypes.ATX_5,
MarkdownElementTypes.ATX_6 -> {
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
}
@ -341,7 +386,7 @@ private fun annotateText(
// 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))
append(annotateText(state, child, revealedSpoilers))
}
}
}
@ -351,17 +396,21 @@ private fun annotateText(
// 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))
append(annotateText(state, child, revealedSpoilers))
}
}
}
GFMElementTypes.STRIKETHROUGH -> {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
// Skip the first two children and the last two children
// because they are the tilde characters
for (child in node.children.subList(2, node.children.size - 2)) {
append(annotateText(state, child))
// More defensive children handling
val childrenToProcess = if (node.children.size >= 4) {
node.children.subList(2, node.children.size - 2)
} else {
node.children
}
for (child in childrenToProcess) {
append(annotateText(state, child, revealedSpoilers))
}
}
}
@ -377,7 +426,7 @@ private fun annotateText(
removeItemCount,
node.children.size - removeItemCount
)) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
}
}
@ -396,7 +445,7 @@ private fun annotateText(
MarkdownElementTypes.ORDERED_LIST,
MarkdownElementTypes.LIST_ITEM -> {
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
}
@ -409,18 +458,36 @@ private fun annotateText(
append(" ")
}
MarkdownElementTypes.PARAGRAPH,
MarkdownElementTypes.PARAGRAPH -> {
val paragraphText = node.getTextInNode(sourceText).toString()
if (paragraphText.contains("||")) {
android.util.Log.d("JBMRenderer", "Paragraph contains spoilers: '$paragraphText'")
val spoilerRegex = Regex("\\|\\|(.+?)\\|\\|")
if (spoilerRegex.containsMatchIn(paragraphText)) {
processSpoilerParagraph(paragraphText, state, revealedSpoilers, surfaceContainer, onSurface, contentColor)
} else {
for (child in node.children) {
append(annotateText(state, child, revealedSpoilers))
}
}
} else {
for (child in node.children) {
append(annotateText(state, child, revealedSpoilers))
}
}
}
MarkdownElementTypes.HTML_BLOCK,
MarkdownTokenTypes.HTML_TAG -> {
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
}
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))
append(annotateText(state, child, revealedSpoilers))
}
}
@ -493,30 +560,67 @@ private fun annotateText(
append("[${node.type.name}]{\n")
}
for (child in node.children) {
append(annotateText(state, child))
append(annotateText(state, child, revealedSpoilers))
}
withStyle(SpanStyle(color = Color.Cyan)) {
append("\n}")
}
}
}
}
} catch (e: Exception) {
buildAnnotatedString {
withStyle(SpanStyle(color = Color(0xFFFF0000), background = Color(0xFF000000))) {
append("[${node.type.name}] Error: ${e.message}")
}
Log.e("JBMRenderer", "Error rendering node: ${node.type.name}", e)
}
}
}
private fun AnnotatedString.Builder.processSpoilerParagraph(
text: String,
state: JBMarkdownTreeState,
revealedSpoilers: Set<String>,
surfaceContainer: Color,
onSurface: Color,
contentColor: Color
) {
val spoilerRegex = Regex("\\|\\|(.+?)\\|\\|")
var lastIndex = 0
android.util.Log.d("JBMRenderer", "Processing spoiler paragraph: '$text'")
for (match in spoilerRegex.findAll(text)) {
android.util.Log.d("JBMRenderer", "Found spoiler match: '${match.value}'")
// Append text before spoiler
append(text.substring(lastIndex, match.range.first))
// Handle spoiler
val spoilerContent = match.groupValues[1]
val spoilerId = "spoiler_${match.range.first}_${spoilerContent.hashCode()}"
val isRevealed = revealedSpoilers.contains(spoilerId)
android.util.Log.d("JBMRenderer", "Spoiler content: '$spoilerContent', revealed: $isRevealed")
pushStringAnnotation(
tag = JBMAnnotations.Spoiler.tag,
annotation = spoilerId
)
withStyle(
SpanStyle(
background = if (isRevealed) surfaceContainer else onSurface,
color = if (isRevealed) contentColor else onSurface
)
) {
append(spoilerContent)
}
pop()
lastIndex = match.range.last + 1
}
append(text.substring(lastIndex))
}
@Composable
private fun JBMText(node: ASTNode, modifier: Modifier) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val mdState = LocalJBMarkdownTreeState.current
val annotatedText = remember(node) { annotateText(mdState, node) }
val annotatedText = annotateText(mdState, node, mdState.revealedSpoilers)
val colours = MaterialTheme.colorScheme
val scope = rememberCoroutineScope()
val context = LocalContext.current
@ -618,6 +722,11 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
}
return@handler true
}
JBMAnnotations.Spoiler.tag -> {
mdState.onSpoilerToggle?.invoke(item)
return@handler true
}
}
}
}

View File

@ -21,4 +21,7 @@ object RSMElementTypes {
@JvmField
val TIMESTAMP: IElementType = MarkdownElementType("TIMESTAMP")
@JvmField
val SPOILER: IElementType = MarkdownElementType("SPOILER")
}

View File

@ -4,6 +4,7 @@ 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
@ -24,18 +25,19 @@ class RSMFlavourDescriptor : GFMFlavourDescriptor() {
override val sequentialParserManager = object : SequentialParserManager() {
override fun getParserSequence(): List<SequentialParser> {
return listOf(
UserMentionParser(),
ChannelMentionParser(),
RoleMentionParser(),
MassMentionParser(),
CustomEmoteParser(),
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
BacktickParser(),
MathParser(),
ImageParser(),
InlineLinkParser(),
ReferenceLinkParser(),
EmphasisLikeParser(EmphStrongDelimiterParser(), StrikeThroughDelimiterParser())
EmphasisLikeParser(EmphStrongDelimiterParser(), StrikeThroughDelimiterParser()),
UserMentionParser(),
ChannelMentionParser(),
RoleMentionParser(),
MassMentionParser(),
CustomEmoteParser(),
SpoilerParser()
)
}
}

View File

@ -0,0 +1,72 @@
package chat.revolt.markdown.jbm.sequentialparsers
import chat.revolt.markdown.jbm.RSMElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class SpoilerParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
android.util.Log.d("SpoilerParser", "SpoilerParser.parse() called with ${rangesToGlue.size} ranges")
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
android.util.Log.d("SpoilerParser", "Processing token: ${iterator.type}, text: '${iterator.toString()}'")
if (iterator.type == MarkdownTokenTypes.TEXT) {
val text = iterator.toString()
if (text == "||" || text.startsWith("||") || text.contains("||")) {
android.util.Log.d("SpoilerParser", "Found potential spoiler start: '$text'")
val start = iterator.index
var searchIterator = iterator.advance()
var foundContent = false
var foundEnd = false
while (searchIterator.type != null) {
val searchText = searchIterator.toString()
android.util.Log.d("SpoilerParser", "Scanning: '${searchText}' (${searchIterator.type})")
if (searchText == "||" || searchText.endsWith("||") || searchText.contains("||")) {
android.util.Log.d("SpoilerParser", "Found potential spoiler end: '$searchText'")
foundEnd = true
result.withNode(
SequentialParser.Node(
start..searchIterator.index,
RSMElementTypes.SPOILER
)
)
android.util.Log.d("SpoilerParser", "Created SPOILER node from $start to ${searchIterator.index}")
iterator = searchIterator.advance()
break
}
if (searchIterator.type == MarkdownTokenTypes.TEXT || searchIterator.type.toString() == "WHITE_SPACE") {
foundContent = true
}
searchIterator = searchIterator.advance()
}
if (foundEnd) {
continue
} else {
android.util.Log.d("SpoilerParser", "No closing || found")
}
}
}
delegateIndices.put(iterator.index)
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
}

View File

@ -47,7 +47,7 @@ import chat.revolt.settings.dsl.SubcategoryContentInsets
import kotlinx.coroutines.launch
enum class MarkdownRenderer {
Stendal, JetBrains, FinalMarkdown
JetBrains, FinalMarkdown
}
class ExperimentsSettingsScreenViewModel : ViewModel() {
@ -65,7 +65,7 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
}
else -> {
mdRenderer.value = MarkdownRenderer.Stendal
mdRenderer.value = MarkdownRenderer.JetBrains
}
}
usePolarChecked.value = Experiments.usePolar.isEnabled
@ -98,18 +98,11 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
}
}
val mdRenderer = mutableStateOf(MarkdownRenderer.Stendal)
val mdRenderer = mutableStateOf(MarkdownRenderer.JetBrains)
fun setMdRenderer(value: MarkdownRenderer) {
viewModelScope.launch {
when (value) {
MarkdownRenderer.Stendal -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
kv.set("exp/useFinalMarkdownRenderer", false)
Experiments.useFinalMarkdownRenderer.setEnabled(false)
}
MarkdownRenderer.JetBrains -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", true)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(true)
@ -210,9 +203,8 @@ fun ExperimentsSettingsScreen(
},
supportingContent = {
when (viewModel.mdRenderer.value) {
MarkdownRenderer.Stendal -> Text("Use the original C++ Markdown renderer for messages.")
MarkdownRenderer.JetBrains -> Text("Use the Kotlin-based JetBrains Markdown renderer for messages. This renderer is more feature-complete and has better results.")
MarkdownRenderer.FinalMarkdown -> Text("Use a new. blazingly fast markdown renderer for messages. This renderer is experimental and may have missing features.")
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.FinalMarkdown -> Text("Use a new blazingly fast markdown renderer for messages. This renderer is experimental and may have missing features.")
}
},
modifier = Modifier
@ -225,15 +217,6 @@ fun ExperimentsSettingsScreen(
.padding(end = 8.dp),
verticalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.Stendal,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.Stendal) },
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Default")
}
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.JetBrains,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.JetBrains) },
@ -241,7 +224,7 @@ fun ExperimentsSettingsScreen(
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Kotlin")
Text("JetBrains")
}
if (FeatureFlags.finalMarkdownGranted || viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown) {
ToggleButton(