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:
+
+
+
+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(