From ea692b18dcc48989320e7aab4c70857952f959ae Mon Sep 17 00:00:00 2001 From: Alex Yong Date: Sat, 30 Aug 2025 04:39:10 -0400 Subject: [PATCH] adding in better emoji handling, a recently used emoji section, and better markdown handling --- README.md | 54 ++++- app/build.gradle.kts | 2 +- app/src/main/cpp/stendal/stendal.cpp | 15 +- .../java/chat/revolt/RevoltApplication.kt | 8 + .../main/java/chat/revolt/api/RevoltAPI.kt | 3 + .../revolt/api/realtime/RealtimeSocket.kt | 3 + .../chat/revolt/api/settings/Experiments.kt | 2 +- .../chat/revolt/composables/chat/Message.kt | 41 ++-- .../revolt/composables/chat/MessageField.kt | 27 ++- .../revolt/composables/emoji/EmojiPicker.kt | 196 +++++++++++++++--- .../composables/markdown/MarkdownText.kt | 90 +++++++- .../composables/markdown/MarkdownTree.kt | 34 +++ .../composables/markdown/RichMarkdown.kt | 12 +- .../java/chat/revolt/internals/EmojiImpl.kt | 143 +++++++++---- .../revolt/internals/EmojiUsageTracker.kt | 82 ++++++++ .../chat/revolt/markdown/jbm/JBMRenderer.kt | 169 ++++++++++++--- .../revolt/markdown/jbm/RSMElementTypes.kt | 3 + .../markdown/jbm/RSMFlavourDescriptor.kt | 14 +- .../jbm/sequentialparsers/SpoilerParser.kt | 72 +++++++ .../settings/ExperimentsSettingsScreen.kt | 29 +-- 20 files changed, 817 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/chat/revolt/internals/EmojiUsageTracker.kt create mode 100644 app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/SpoilerParser.kt diff --git a/README.md b/README.md index 9914fdd9..0893673b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: + +image + +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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5daa312..9e832e4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/cpp/stendal/stendal.cpp b/app/src/main/cpp/stendal/stendal.cpp index 8d033221..f8224168 100644 --- a/app/src/main/cpp/stendal/stendal.cpp +++ b/app/src/main/cpp/stendal/stendal.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #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, "$1"); + + 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); diff --git a/app/src/main/java/chat/revolt/RevoltApplication.kt b/app/src/main/java/chat/revolt/RevoltApplication.kt index a9936361..fa05fa8a 100644 --- a/app/src/main/java/chat/revolt/RevoltApplication.kt +++ b/app/src/main/java/chat/revolt/RevoltApplication.kt @@ -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 { diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index a5c7c77e..3fe10c9a 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -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() diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index 3c1f8894..0e11df95 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -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() diff --git a/app/src/main/java/chat/revolt/api/settings/Experiments.kt b/app/src/main/java/chat/revolt/api/settings/Experiments.kt index 41edc140..cfed6040 100644 --- a/app/src/main/java/chat/revolt/api/settings/Experiments.kt +++ b/app/src/main/java/chat/revolt/api/settings/Experiments.kt @@ -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) diff --git a/app/src/main/java/chat/revolt/composables/chat/Message.kt b/app/src/main/java/chat/revolt/composables/chat/Message.kt index 0e5bf881..132436e9 100644 --- a/app/src/main/java/chat/revolt/composables/chat/Message.kt +++ b/app/src/main/java/chat/revolt/composables/chat/Message.kt @@ -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) } } } diff --git a/app/src/main/java/chat/revolt/composables/chat/MessageField.kt b/app/src/main/java/chat/revolt/composables/chat/MessageField.kt index 1036ff6f..b6ba9592 100644 --- a/app/src/main/java/chat/revolt/composables/chat/MessageField.kt +++ b/app/src/main/java/chat/revolt/composables/chat/MessageField.kt @@ -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() } } ) diff --git a/app/src/main/java/chat/revolt/composables/emoji/EmojiPicker.kt b/app/src/main/java/chat/revolt/composables/emoji/EmojiPicker.kt index 81cb153f..fb8f7cdb 100644 --- a/app/src/main/java/chat/revolt/composables/emoji/EmojiPicker.kt +++ b/app/src/main/java/chat/revolt/composables/emoji/EmojiPicker.kt @@ -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() - .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() } - 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 ) diff --git a/app/src/main/java/chat/revolt/composables/markdown/MarkdownText.kt b/app/src/main/java/chat/revolt/composables/markdown/MarkdownText.kt index 7978ce9f..6fcac15b 100644 --- a/app/src/main/java/chat/revolt/composables/markdown/MarkdownText.kt +++ b/app/src/main/java/chat/revolt/composables/markdown/MarkdownText.kt @@ -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("") val UrlFallback = Regex("?") @@ -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 = 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(null) } - val annotatedText = annotateText(textNode) + var revealedSpoilers by remember { mutableStateOf(setOf()) } + 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 diff --git a/app/src/main/java/chat/revolt/composables/markdown/MarkdownTree.kt b/app/src/main/java/chat/revolt/composables/markdown/MarkdownTree.kt index 4a17ceb7..f8cdd220 100644 --- a/app/src/main/java/chat/revolt/composables/markdown/MarkdownTree.kt +++ b/app/src/main/java/chat/revolt/composables/markdown/MarkdownTree.kt @@ -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 + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/markdown/RichMarkdown.kt b/app/src/main/java/chat/revolt/composables/markdown/RichMarkdown.kt index f09a8eca..492dffb2 100644 --- a/app/src/main/java/chat/revolt/composables/markdown/RichMarkdown.kt +++ b/app/src/main/java/chat/revolt/composables/markdown/RichMarkdown.kt @@ -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) + } } + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/EmojiImpl.kt b/app/src/main/java/chat/revolt/internals/EmojiImpl.kt index 15801c4c..d684b260 100644 --- a/app/src/main/java/chat/revolt/internals/EmojiImpl.kt +++ b/app/src/main/java/chat/revolt/internals/EmojiImpl.kt @@ -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 - - private fun initMetadata(context: Context): List { - val json = context.assets.open("metadata/emoji.json").use { - it.reader().readText() +object EmojiRepository { + private var metadata: List? = null + private val serverEmojiCache = ConcurrentHashMap>() + private val serverListCache = ConcurrentHashMap>() + private var cachedPickerList: List? = null + private var cachedCategorySpans: Map>? = null + + val isReady = mutableStateOf(false) + val isLoading = mutableStateOf(false) + + private suspend fun initMetadata(context: Context): List { + 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 { - 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 { - val list = mutableListOf() - 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() + 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 { + val currentMetadata = metadata ?: return emptyList() + + val cacheKey = "${serversWithEmotes().size}_${RevoltAPI.emojiCache.values.size}" + cachedPickerList?.let { cached -> + if (cacheKey == lastCacheKey) return cached + } + val list = mutableListOf() 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): Map> { + cachedCategorySpans?.let { cached -> + if (lastCacheKey != null) return cached + } + val output = mutableMapOf>() 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 { + val currentMetadata = metadata ?: return emptyList() + if (query.isBlank()) return emptyList() + val list = mutableListOf() 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 { - 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 diff --git a/app/src/main/java/chat/revolt/internals/EmojiUsageTracker.kt b/app/src/main/java/chat/revolt/internals/EmojiUsageTracker.kt new file mode 100644 index 00000000..35163123 --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/EmojiUsageTracker.kt @@ -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 = emptyMap(), + val lastUsed: Map = emptyMap() +) + +object EmojiUsageTracker { + private const val PREFS_NAME = "emoji_usage" + private const val KEY_DATA = "usage_data" + + private var usageCount = mutableMapOf() + private var lastUsed = mutableMapOf() + + init { + loadFromStorage() + } + + fun recordUsage(emoji: String) { + usageCount[emoji] = (usageCount[emoji] ?: 0) + 1 + lastUsed[emoji] = System.currentTimeMillis() + saveToStorage() + } + + fun getRecentlyUsed(limit: Int = 12): List { + if (lastUsed.isEmpty()) return emptyList() + + return lastUsed.entries + .sortedByDescending { it.value } + .take(limit) + .map { it.key } + } + + fun getMostUsed(limit: Int = 12): List { + if (usageCount.isEmpty()) return emptyList() + + return usageCount.entries + .sortedWith( + compareByDescending> { 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(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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt b/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt index ca8abc97..2b9d7c05 100644 --- a/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt +++ b/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt @@ -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 = 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()) } 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 = 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, + 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(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 + } } } } diff --git a/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt b/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt index 72c57355..154b247e 100644 --- a/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt +++ b/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt @@ -21,4 +21,7 @@ object RSMElementTypes { @JvmField val TIMESTAMP: IElementType = MarkdownElementType("TIMESTAMP") + + @JvmField + val SPOILER: IElementType = MarkdownElementType("SPOILER") } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt b/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt index b3f51b0b..cffda89d 100644 --- a/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt +++ b/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt @@ -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 { 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() ) } } diff --git a/app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/SpoilerParser.kt b/app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/SpoilerParser.kt new file mode 100644 index 00000000..cd3464e3 --- /dev/null +++ b/app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/SpoilerParser.kt @@ -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 + ): 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()) + } +} diff --git a/app/src/main/java/chat/revolt/screens/settings/ExperimentsSettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/ExperimentsSettingsScreen.kt index fce5374f..e0c33cd7 100644 --- a/app/src/main/java/chat/revolt/screens/settings/ExperimentsSettingsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/settings/ExperimentsSettingsScreen.kt @@ -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(