adding in better emoji handling, a recently used emoji section, and better markdown handling
This commit is contained in:
parent
f05d010007
commit
ea692b18dc
54
README.md
54
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
|
||||
<table>
|
||||
|
|
@ -122,6 +125,51 @@ Or get the apk [directly from here](https://github.com/psanford/wormhole-william
|
|||
Or install [Termux](https://termux.dev/en/), then install `wormhole-rs` on Termux with `pkg install magic-wormhole-rs` and fetch the apk with
|
||||
`wormhole-rs receive YOUR_CODE_HERE`
|
||||
|
||||
## Quick Build
|
||||
|
||||
If you don't want to download the apks in the releases section and rather build yourself, follow these steps:
|
||||
|
||||
Fire up a Github Codespaces instance at this link [here](https://github.com/codespaces/new?hide_repo_select=true&ref=combined-pr&repo=1020437871&skip_quickstart=true&machine=premiumLinux&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsEast)
|
||||
|
||||
The URL should have it selected for you automatically, but be sure to use this branch for your instance!
|
||||
|
||||
Note that this url will have an 8-core instance selected by default. Feel free to use a smaller instance, but I've ran into build errors with that.
|
||||
At the time of writing, Github offers a number of free hours for personal accounts, but note that this bigger instance will use more of your free hours than a smaller one. **For just building the apk and downloading it to whatever device, this should be fine though**. Be sure to delete the instance when you are done. **It won't cost you $$ if you don't have payment set up with Github or have budget limits.** [See the billing page for more details.](https://docs.github.com/en/billing/concepts/product-billing/github-codespaces#pricing-for-paid-usage)
|
||||
|
||||
After the instance fires up run
|
||||
|
||||
```sh
|
||||
./gradlew assembledebug --no-daemon
|
||||
```
|
||||
To generate a debug version of the application.
|
||||
|
||||
If you wanted a signed copy that isn't in debug mode, set up a release-key.keystore file, update revoltbuild.properties to have your passwords and run:
|
||||
|
||||
```sh
|
||||
./gradlew assembleRelease -x app:uploadSentryProguardMappingsRelease
|
||||
```
|
||||
|
||||
It will be located in `app/build/outputs/apk/debug/` under the name `app-debug.apk`
|
||||
|
||||
Download it to your system by right clicking on the file like so:
|
||||
|
||||
<img width="455" height="596" alt="image" src="https://github.com/user-attachments/assets/2fdffb6b-0fdc-4131-97b4-3360ae8871d8" />
|
||||
|
||||
Send it to your phone, and install and run it!
|
||||
|
||||
Alternatively, you can send it to your phone right from Codespaces using magic wormhole (installed on this codespace instance by default)
|
||||
|
||||
`wormhole send app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
It will give you a code that you can punch into your phone.
|
||||
|
||||
Either use the [Wormhole William app from the Google Playstore](https://play.google.com/store/apps/details?id=io.sanford.wormhole_william&hl=en_US)
|
||||
|
||||
Or get the apk [directly from here](https://github.com/psanford/wormhole-william-mobile/releases/tag/v1.0.13)
|
||||
|
||||
Or install [Termux](https://termux.dev/en/), then install `wormhole-rs` on Termux with `pkg install magic-wormhole-rs` and fetch the apk with
|
||||
`wormhole-rs receive YOUR_CODE_HERE`
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open the project in Android Studio. You can then run the app on an emulator or a physical device by
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <regex>
|
||||
#include <cmark.h>
|
||||
|
||||
#define STENDAL_ASTNODE_CONSTRUCTOR_SIGNATURE "(ILjava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;)V"
|
||||
|
|
@ -26,6 +27,15 @@ namespace Stendal {
|
|||
return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin());
|
||||
}
|
||||
|
||||
std::string preprocess_markdown(const std::string& input) {
|
||||
std::string result = input;
|
||||
|
||||
std::regex spoiler_regex(R"(\|\|(.+?)\|\|)");
|
||||
result = std::regex_replace(result, spoiler_regex, "<spoiler>$1</spoiler>");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void init(JNIEnv *env) {
|
||||
jclass localArrayListClass = env->FindClass("java/util/ArrayList");
|
||||
arrayListClass = (jclass) env->NewGlobalRef(localArrayListClass);
|
||||
|
|
@ -148,7 +158,10 @@ Java_chat_revolt_ndk_Stendal_init(JNIEnv *env, [[maybe_unused]] jobject thiz) {
|
|||
extern "C" JNIEXPORT jobject JNICALL
|
||||
Java_chat_revolt_ndk_Stendal_render(JNIEnv *env, [[maybe_unused]] jobject thiz, jstring input) {
|
||||
const char *inputStr = env->GetStringUTFChars(input, nullptr);
|
||||
cmark_node *doc = cmark_parse_document(inputStr, strlen(inputStr),
|
||||
|
||||
std::string preprocessed = Stendal::preprocess_markdown(std::string(inputStr));
|
||||
|
||||
cmark_node *doc = cmark_parse_document(preprocessed.c_str(), preprocessed.length(),
|
||||
CMARK_OPT_DEFAULT | CMARK_OPT_HARDBREAKS |
|
||||
CMARK_OPT_VALIDATE_UTF8);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
|
|
@ -30,6 +31,7 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
|
|
@ -68,10 +70,12 @@ import chat.revolt.callbacks.ActionChannel
|
|||
import chat.revolt.composables.generic.IconPlaceholder
|
||||
import chat.revolt.composables.generic.RemoteImage
|
||||
import chat.revolt.internals.Category
|
||||
import chat.revolt.internals.EmojiImpl
|
||||
import chat.revolt.internals.EmojiRepository
|
||||
import chat.revolt.internals.EmojiPickerItem
|
||||
import chat.revolt.internals.EmojiUsageTracker
|
||||
import chat.revolt.internals.FitzpatrickSkinTone
|
||||
import chat.revolt.internals.UnicodeEmojiSection
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
|
@ -82,17 +86,28 @@ fun EmojiPicker(
|
|||
) {
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val emojiImpl = remember { EmojiImpl() }
|
||||
val pickerList = remember(emojiImpl) { emojiImpl.flatPickerList() }
|
||||
val servers = remember(emojiImpl) { emojiImpl.serversWithEmotes() }
|
||||
val categorySpans = remember(pickerList) { emojiImpl.categorySpans(pickerList) }
|
||||
LaunchedEffect(Unit) {
|
||||
EmojiRepository.initialize(scope)
|
||||
}
|
||||
|
||||
val isReady by EmojiRepository.isReady
|
||||
val isLoading by EmojiRepository.isLoading
|
||||
|
||||
val pickerList = remember(isReady) {
|
||||
if (isReady) EmojiRepository.flatPickerList() else emptyList()
|
||||
}
|
||||
val servers = remember(isReady) {
|
||||
if (isReady) EmojiRepository.serversWithEmotes() else emptyList()
|
||||
}
|
||||
val categorySpans = remember(pickerList) {
|
||||
if (isReady && pickerList.isNotEmpty()) EmojiRepository.categorySpans(pickerList) else emptyMap()
|
||||
}
|
||||
|
||||
val gridState = rememberLazyGridState()
|
||||
val categoryRowScrollState = rememberScrollState()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val spanCount = 9 // https://github.com/googlefonts/emoji-metadata/#readme
|
||||
|
||||
// The current category is the one that the user is currently looking at.
|
||||
|
|
@ -144,7 +159,7 @@ fun EmojiPicker(
|
|||
val skinSample = remember(pickerList) {
|
||||
pickerList
|
||||
.filterIsInstance<EmojiPickerItem.UnicodeEmoji>()
|
||||
.first { it.character == "\uD83E\uDEF0" }
|
||||
.firstOrNull { it.character == "\uD83E\uDEF0" }
|
||||
}
|
||||
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
|
|
@ -155,11 +170,28 @@ fun EmojiPicker(
|
|||
)
|
||||
|
||||
val searchResults = remember { mutableStateListOf<EmojiPickerItem>() }
|
||||
LaunchedEffect(searchQuery) {
|
||||
var isSearching by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(searchQuery, isReady) {
|
||||
searchResults.clear()
|
||||
if (searchQuery.isBlank()) return@LaunchedEffect
|
||||
searchResults.addAll(emojiImpl.searchForEmoji(searchQuery))
|
||||
gridState.scrollToItem(0)
|
||||
if (searchQuery.isBlank() || !isReady) {
|
||||
isSearching = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
delay(300)
|
||||
|
||||
try {
|
||||
val results = EmojiRepository.searchForEmoji(searchQuery)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
if (results.isNotEmpty()) {
|
||||
gridState.scrollToItem(0)
|
||||
}
|
||||
} finally {
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
val onServerEmoteInfo: (String) -> Unit = {
|
||||
|
|
@ -175,14 +207,16 @@ fun EmojiPicker(
|
|||
val onEmojiClick: (EmojiPickerItem) -> Unit = {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
when (it) {
|
||||
is EmojiPickerItem.UnicodeEmoji -> onEmojiSelected(
|
||||
emojiImpl.applyFitzpatrickSkinTone(
|
||||
it,
|
||||
currentSkinTone
|
||||
)
|
||||
)
|
||||
|
||||
is EmojiPickerItem.ServerEmote -> onEmojiSelected(":${it.emote.id}:")
|
||||
is EmojiPickerItem.UnicodeEmoji -> {
|
||||
val emojiChar = EmojiRepository.applyFitzpatrickSkinTone(it, currentSkinTone)
|
||||
EmojiUsageTracker.recordUsage(emojiChar)
|
||||
onEmojiSelected(emojiChar)
|
||||
}
|
||||
is EmojiPickerItem.ServerEmote -> {
|
||||
val emojiCode = ":${it.emote.id}:"
|
||||
EmojiUsageTracker.recordUsage(emojiCode)
|
||||
onEmojiSelected(emojiCode)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
|
@ -195,7 +229,28 @@ fun EmojiPicker(
|
|||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.heightIn(min = 400.dp)
|
||||
) {
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = stringResource(R.string.loading),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -268,10 +323,12 @@ fun EmojiPicker(
|
|||
) {
|
||||
FitzpatrickSkinTone.entries.forEach { skinTone ->
|
||||
Text(
|
||||
emojiImpl.applyFitzpatrickSkinTone(
|
||||
skinSample,
|
||||
skinTone
|
||||
),
|
||||
skinSample?.let { sample ->
|
||||
EmojiRepository.applyFitzpatrickSkinTone(
|
||||
sample,
|
||||
skinTone
|
||||
)
|
||||
} ?: "",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.requiredSize(24.dp)
|
||||
|
|
@ -327,10 +384,12 @@ fun EmojiPicker(
|
|||
.aspectRatio(1f)
|
||||
) {
|
||||
Text(
|
||||
emojiImpl.applyFitzpatrickSkinTone(
|
||||
skinSample,
|
||||
currentSkinTone
|
||||
),
|
||||
skinSample?.let { sample ->
|
||||
EmojiRepository.applyFitzpatrickSkinTone(
|
||||
sample,
|
||||
currentSkinTone
|
||||
)
|
||||
} ?: "",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.requiredSize(24.dp)
|
||||
|
|
@ -354,6 +413,66 @@ fun EmojiPicker(
|
|||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
val recentEmojis = remember(isReady) {
|
||||
if (isReady) EmojiUsageTracker.getRecentlyUsed() else emptyList()
|
||||
}
|
||||
|
||||
AnimatedVisibility(searchResults.isEmpty() && recentEmojis.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Recently Used",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
recentEmojis.forEach { emoji ->
|
||||
if (emoji.matches(Regex(":[0-9A-Z]{26}:"))) {
|
||||
val emojiId = emoji.removeSurrounding(":", ":")
|
||||
RemoteImage(
|
||||
url = "$REVOLT_FILES/emojis/$emojiId",
|
||||
description = emoji,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
EmojiUsageTracker.recordUsage(emoji)
|
||||
onEmojiSelected(emoji)
|
||||
}
|
||||
.padding(4.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 24.sp,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
EmojiUsageTracker.recordUsage(emoji)
|
||||
onEmojiSelected(emoji)
|
||||
}
|
||||
.padding(4.dp)
|
||||
.size(24.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(searchResults.isEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -513,9 +632,16 @@ fun EmojiPicker(
|
|||
}
|
||||
}
|
||||
|
||||
// Search results do not get a key, this is intentional.
|
||||
items(
|
||||
searchResults.size,
|
||||
key = { index ->
|
||||
val item = searchResults[index]
|
||||
when (item) {
|
||||
is EmojiPickerItem.UnicodeEmoji -> "search_unicode_${item.character}"
|
||||
is EmojiPickerItem.ServerEmote -> "search_server_${item.emote.id}"
|
||||
is EmojiPickerItem.Section -> "search_section_${item.category.hashCode()}"
|
||||
}
|
||||
},
|
||||
span = {
|
||||
val item = searchResults[it]
|
||||
when (item) {
|
||||
|
|
@ -527,7 +653,7 @@ fun EmojiPicker(
|
|||
) { index ->
|
||||
PickerItem(
|
||||
item = searchResults[index],
|
||||
skinToneFactory = { emojiImpl.applyFitzpatrickSkinTone(it, currentSkinTone) },
|
||||
skinToneFactory = { EmojiRepository.applyFitzpatrickSkinTone(it, currentSkinTone) },
|
||||
onClick = onEmojiClick,
|
||||
onServerEmoteInfo = onServerEmoteInfo,
|
||||
lesserHeaders = true
|
||||
|
|
@ -547,6 +673,14 @@ fun EmojiPicker(
|
|||
|
||||
items(
|
||||
pickerList.size,
|
||||
key = { index ->
|
||||
val item = pickerList[index]
|
||||
when (item) {
|
||||
is EmojiPickerItem.UnicodeEmoji -> "unicode_${item.character}"
|
||||
is EmojiPickerItem.ServerEmote -> "server_${item.emote.id}"
|
||||
is EmojiPickerItem.Section -> "section_${item.category.hashCode()}"
|
||||
}
|
||||
},
|
||||
span = {
|
||||
val item = pickerList[it]
|
||||
when (item) {
|
||||
|
|
@ -558,7 +692,7 @@ fun EmojiPicker(
|
|||
) { index ->
|
||||
PickerItem(
|
||||
item = pickerList[index],
|
||||
skinToneFactory = { emojiImpl.applyFitzpatrickSkinTone(it, currentSkinTone) },
|
||||
skinToneFactory = { EmojiRepository.applyFitzpatrickSkinTone(it, currentSkinTone) },
|
||||
onClick = onEmojiClick,
|
||||
onServerEmoteInfo = onServerEmoteInfo
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontSynthesis
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.core.net.toUri
|
||||
import chat.revolt.R
|
||||
import chat.revolt.activities.InviteActivity
|
||||
|
|
@ -43,6 +45,7 @@ import chat.revolt.callbacks.Action
|
|||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.composables.generic.RemoteImage
|
||||
import chat.revolt.composables.utils.detectTapGesturesConditionalConsume
|
||||
import chat.revolt.internals.EmojiRepository
|
||||
import chat.revolt.internals.resolveTimestamp
|
||||
import chat.revolt.ndk.AstNode
|
||||
import chat.revolt.ui.theme.FragmentMono
|
||||
|
|
@ -54,6 +57,7 @@ enum class Annotations(val tag: String, val clickable: Boolean) {
|
|||
UserMention("UserMention", true),
|
||||
ChannelMention("ChannelMention", true),
|
||||
CustomEmote("CustomEmote", true),
|
||||
Spoiler("Spoiler", true),
|
||||
Timestamp("Timestamp", false)
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +65,8 @@ object MarkdownTextRegularExpressions {
|
|||
val Mention = Regex("<@([0-9A-Z]{26})>")
|
||||
val Channel = Regex("<#([0-9A-Z]{26})>")
|
||||
val CustomEmote = Regex(":([0-9A-Z]{26}):")
|
||||
val UnicodeEmote = Regex(":([a-zA-Z0-9_+-]+):")
|
||||
val Spoiler = Regex("\\|\\|(.+?)\\|\\|")
|
||||
val Timestamp = Regex("<t:([0-9]+?)(:[tTDfFR])?>")
|
||||
val UrlFallback =
|
||||
Regex("<?https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)>?")
|
||||
|
|
@ -70,15 +76,21 @@ object MarkdownTextRegularExpressions {
|
|||
* Visit the AST and its children and return an [AnnotatedString] with the appropriate annotations.
|
||||
*/
|
||||
@Composable
|
||||
fun annotateText(node: AstNode): AnnotatedString {
|
||||
fun annotateText(node: AstNode, revealedSpoilers: Set<String> = emptySet()): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
when (node.stringType) {
|
||||
"text" -> {
|
||||
val text = node.text ?: ""
|
||||
var text = node.text ?: ""
|
||||
|
||||
text = MarkdownTextRegularExpressions.UnicodeEmote.replace(text) { matchResult ->
|
||||
val shortcodeName = matchResult.groupValues[1]
|
||||
EmojiRepository.unicodeByShortcode(shortcodeName) ?: matchResult.value
|
||||
}
|
||||
|
||||
val mentions = MarkdownTextRegularExpressions.Mention.findAll(text)
|
||||
val channels = MarkdownTextRegularExpressions.Channel.findAll(text)
|
||||
val customEmotes = MarkdownTextRegularExpressions.CustomEmote.findAll(text)
|
||||
val spoilers = MarkdownTextRegularExpressions.Spoiler.findAll(text)
|
||||
val timestamps = MarkdownTextRegularExpressions.Timestamp.findAll(text)
|
||||
val urls = MarkdownTextRegularExpressions.UrlFallback.findAll(text)
|
||||
|
||||
|
|
@ -153,6 +165,32 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
lastIndex = emote.range.last + 1
|
||||
}
|
||||
|
||||
for (spoiler in spoilers) {
|
||||
try {
|
||||
append(text.substring(lastIndex, spoiler.range.first))
|
||||
} catch (e: Exception) {
|
||||
// no-op
|
||||
}
|
||||
val spoilerContent = spoiler.groupValues[1]
|
||||
val spoilerId = "spoiler_${spoiler.range.first}_${spoilerContent.hashCode()}"
|
||||
val isRevealed = revealedSpoilers.contains(spoilerId)
|
||||
|
||||
pushStringAnnotation(
|
||||
tag = Annotations.Spoiler.tag,
|
||||
annotation = spoilerId
|
||||
)
|
||||
pushStyle(
|
||||
LocalTextStyle.current.toSpanStyle().copy(
|
||||
background = if (isRevealed) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.onSurface,
|
||||
color = if (isRevealed) LocalContentColor.current else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
append(spoilerContent)
|
||||
pop()
|
||||
pop()
|
||||
lastIndex = spoiler.range.last + 1
|
||||
}
|
||||
|
||||
for (timestamp in timestamps) {
|
||||
try {
|
||||
append(text.substring(lastIndex, timestamp.range.first))
|
||||
|
|
@ -219,7 +257,7 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
fontSynthesis = FontSynthesis.All
|
||||
)
|
||||
)
|
||||
node.children?.forEach { append(annotateText(it)) }
|
||||
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
|
||||
pop()
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +269,19 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
fontSynthesis = FontSynthesis.All
|
||||
)
|
||||
)
|
||||
node.children?.forEach { append(annotateText(it)) }
|
||||
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
|
||||
pop()
|
||||
}
|
||||
|
||||
"del" -> {
|
||||
pushStyle(
|
||||
LocalTextStyle.current.toSpanStyle()
|
||||
.copy(
|
||||
textDecoration = TextDecoration.LineThrough,
|
||||
fontSynthesis = FontSynthesis.All
|
||||
)
|
||||
)
|
||||
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
|
||||
pop()
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +296,7 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
node.children?.forEach { append(annotateText(it)) }
|
||||
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
|
||||
pop()
|
||||
pop()
|
||||
}
|
||||
|
|
@ -264,12 +314,23 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
pop()
|
||||
}
|
||||
|
||||
"spoiler" -> {
|
||||
pushStyle(
|
||||
LocalTextStyle.current.toSpanStyle().copy(
|
||||
background = MaterialTheme.colorScheme.onSurface,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
|
||||
pop()
|
||||
}
|
||||
|
||||
"softbreak" -> {
|
||||
append("\n")
|
||||
}
|
||||
|
||||
else -> {
|
||||
node.children?.forEach { append(annotateText(it)) }
|
||||
node.children?.forEach { append(annotateText(it, revealedSpoilers)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -278,7 +339,8 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
@Composable
|
||||
fun MarkdownText(textNode: AstNode, modifier: Modifier = Modifier) {
|
||||
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val annotatedText = annotateText(textNode)
|
||||
var revealedSpoilers by remember { mutableStateOf(setOf<String>()) }
|
||||
val annotatedText = annotateText(textNode, revealedSpoilers)
|
||||
val context = LocalContext.current
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -385,6 +447,20 @@ fun MarkdownText(textNode: AstNode, modifier: Modifier = Modifier) {
|
|||
|
||||
return@handler true
|
||||
}
|
||||
|
||||
annotatedText.getStringAnnotations(
|
||||
tag = Annotations.Spoiler.tag,
|
||||
start = offset,
|
||||
end = offset
|
||||
).firstOrNull()?.let { annotation ->
|
||||
val spoilerId = annotation.item
|
||||
revealedSpoilers = if (revealedSpoilers.contains(spoilerId)) {
|
||||
revealedSpoilers - spoilerId
|
||||
} else {
|
||||
revealedSpoilers + spoilerId
|
||||
}
|
||||
return@handler true
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
package chat.revolt.internals
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import chat.revolt.R
|
||||
import chat.revolt.RevoltApplication
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.RevoltJson
|
||||
import chat.revolt.api.schemas.Server
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Serializable
|
||||
data class Emoji(
|
||||
|
|
@ -61,48 +67,86 @@ sealed class EmojiPickerItem {
|
|||
data class ServerEmote(val emote: chat.revolt.api.schemas.Emoji) : EmojiPickerItem()
|
||||
}
|
||||
|
||||
class EmojiImpl {
|
||||
private var metadata: List<EmojiGroup>
|
||||
|
||||
private fun initMetadata(context: Context): List<EmojiGroup> {
|
||||
val json = context.assets.open("metadata/emoji.json").use {
|
||||
it.reader().readText()
|
||||
object EmojiRepository {
|
||||
private var metadata: List<EmojiGroup>? = null
|
||||
private val serverEmojiCache = ConcurrentHashMap<String, List<EmojiPickerItem>>()
|
||||
private val serverListCache = ConcurrentHashMap<Int, List<Server>>()
|
||||
private var cachedPickerList: List<EmojiPickerItem>? = null
|
||||
private var cachedCategorySpans: Map<Category, Pair<Int, Int>>? = null
|
||||
|
||||
val isReady = mutableStateOf(false)
|
||||
val isLoading = mutableStateOf(false)
|
||||
|
||||
private suspend fun initMetadata(context: Context): List<EmojiGroup> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val json = context.assets.open("metadata/emoji.json").use {
|
||||
it.reader().readText()
|
||||
}
|
||||
RevoltJson.decodeFromString(ListSerializer(EmojiGroup.serializer()), json)
|
||||
}
|
||||
}
|
||||
|
||||
fun initialize(scope: CoroutineScope) {
|
||||
if (isReady.value || isLoading.value) return
|
||||
|
||||
scope.launch {
|
||||
isLoading.value = true
|
||||
try {
|
||||
metadata = initMetadata(RevoltApplication.instance.applicationContext)
|
||||
isReady.value = true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
return RevoltJson.decodeFromString(ListSerializer(EmojiGroup.serializer()), json)
|
||||
}
|
||||
|
||||
fun serversWithEmotes(): List<Server> {
|
||||
return RevoltAPI
|
||||
.emojiCache
|
||||
.values
|
||||
.asSequence()
|
||||
.map { it.parent }
|
||||
.filterNotNull()
|
||||
.filter { it.type == "Server" }
|
||||
.map { it.id }
|
||||
.distinct()
|
||||
.mapNotNull { RevoltAPI.serverCache[it] }
|
||||
.toList()
|
||||
val cacheKey = RevoltAPI.emojiCache.values.size
|
||||
return serverListCache.getOrPut(cacheKey) {
|
||||
RevoltAPI
|
||||
.emojiCache
|
||||
.values
|
||||
.asSequence()
|
||||
.map { it.parent }
|
||||
.filterNotNull()
|
||||
.filter { it.type == "Server" }
|
||||
.map { it.id }
|
||||
.distinct()
|
||||
.mapNotNull { RevoltAPI.serverCache[it] }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun serverEmoteList(server: Server): List<EmojiPickerItem> {
|
||||
val list = mutableListOf<EmojiPickerItem>()
|
||||
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
|
||||
val cacheKey = "${server.id}_${RevoltAPI.emojiCache.values.size}"
|
||||
return serverEmojiCache.getOrPut(cacheKey) {
|
||||
val list = mutableListOf<EmojiPickerItem>()
|
||||
val emotes = RevoltAPI.emojiCache.values.filter { it.parent?.id == server.id }
|
||||
|
||||
list.add(EmojiPickerItem.Section(Category.ServerEmoteCategory(server)))
|
||||
list.addAll(emotes.map { EmojiPickerItem.ServerEmote(it) })
|
||||
list.add(EmojiPickerItem.Section(Category.ServerEmoteCategory(server)))
|
||||
list.addAll(emotes.map { EmojiPickerItem.ServerEmote(it) })
|
||||
|
||||
return list
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
fun flatPickerList(): List<EmojiPickerItem> {
|
||||
val currentMetadata = metadata ?: return emptyList()
|
||||
|
||||
val cacheKey = "${serversWithEmotes().size}_${RevoltAPI.emojiCache.values.size}"
|
||||
cachedPickerList?.let { cached ->
|
||||
if (cacheKey == lastCacheKey) return cached
|
||||
}
|
||||
|
||||
val list = mutableListOf<EmojiPickerItem>()
|
||||
|
||||
for (server in serversWithEmotes()) {
|
||||
list.addAll(serverEmoteList(server))
|
||||
}
|
||||
|
||||
for (group in metadata) {
|
||||
for (group in currentMetadata) {
|
||||
val category =
|
||||
UnicodeEmojiSection.entries.find { it.googleName == group.group } ?: continue
|
||||
list.add(EmojiPickerItem.Section(Category.UnicodeEmojiCategory(category)))
|
||||
|
|
@ -121,26 +165,21 @@ class EmojiImpl {
|
|||
)
|
||||
}
|
||||
|
||||
cachedPickerList = list
|
||||
lastCacheKey = cacheKey
|
||||
return list
|
||||
}
|
||||
|
||||
private var lastCacheKey: String? = null
|
||||
|
||||
/**
|
||||
* Returns a map of category to start and end index of the category in the flat picker list
|
||||
* Impl
|
||||
* ====
|
||||
* 1. Iterate through servers that have emotes. Get the index of the server emote category.
|
||||
* 2. Get all emotes in that server. Add the size of that list to the index of the server emote category.
|
||||
* 3. Push Pair(index, index + size) to the map.
|
||||
* 4. Iterate through all unicode emoji categories. Get the index of the category.
|
||||
* Unless it's the last category {
|
||||
* 5.1. Get the index of the next category. Subtract 1 from that index.
|
||||
* 5.2. Push Pair(index, lastIndex) to the map.
|
||||
* } Otherwise {
|
||||
* 5. Push Pair(index, Int.MAX_VALUE) to the map.
|
||||
* }
|
||||
* 6. Return the map.
|
||||
*/
|
||||
fun categorySpans(flatPickerList: List<EmojiPickerItem>): Map<Category, Pair<Int, Int>> {
|
||||
cachedCategorySpans?.let { cached ->
|
||||
if (lastCacheKey != null) return cached
|
||||
}
|
||||
|
||||
val output = mutableMapOf<Category, Pair<Int, Int>>()
|
||||
|
||||
for (server in serversWithEmotes()) {
|
||||
|
|
@ -170,6 +209,7 @@ class EmojiImpl {
|
|||
output[Category.UnicodeEmojiCategory(section)] = Pair(index, lastIndex)
|
||||
}
|
||||
|
||||
cachedCategorySpans = output
|
||||
return output
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +243,9 @@ class EmojiImpl {
|
|||
* query.
|
||||
*/
|
||||
fun searchForEmoji(query: String): List<EmojiPickerItem> {
|
||||
val currentMetadata = metadata ?: return emptyList()
|
||||
if (query.isBlank()) return emptyList()
|
||||
|
||||
val list = mutableListOf<EmojiPickerItem>()
|
||||
|
||||
for (server in serversWithEmotes()) {
|
||||
|
|
@ -215,7 +258,7 @@ class EmojiImpl {
|
|||
}
|
||||
}
|
||||
|
||||
for (group in metadata) {
|
||||
for (group in currentMetadata) {
|
||||
val matchingEmoji = group.emoji.filter {
|
||||
it.shortcodes.any { code ->
|
||||
code.contains(
|
||||
|
|
@ -248,7 +291,8 @@ class EmojiImpl {
|
|||
}
|
||||
|
||||
fun unicodeByShortcode(shortcode: String): String? {
|
||||
return metadata.asSequence().mapNotNull { group ->
|
||||
val currentMetadata = metadata ?: return null
|
||||
return currentMetadata.asSequence().mapNotNull { group ->
|
||||
group.emoji.find { emoji ->
|
||||
emoji.shortcodes.any { code ->
|
||||
code == ":${shortcode}:"
|
||||
|
|
@ -260,7 +304,8 @@ class EmojiImpl {
|
|||
}
|
||||
|
||||
fun shortcodeContains(query: String): List<Emoji> {
|
||||
return metadata.asSequence().map { group ->
|
||||
val currentMetadata = metadata ?: return emptyList()
|
||||
return currentMetadata.asSequence().map { group ->
|
||||
group.emoji.filter { emoji ->
|
||||
emoji.shortcodes.any { code ->
|
||||
code.contains(query, ignoreCase = true)
|
||||
|
|
@ -270,7 +315,8 @@ class EmojiImpl {
|
|||
}
|
||||
|
||||
fun unicodeAsShortcode(unicode: String): String? {
|
||||
return metadata.asSequence().mapNotNull { group ->
|
||||
val currentMetadata = metadata ?: return null
|
||||
return currentMetadata.asSequence().mapNotNull { group ->
|
||||
group.emoji.find { emoji ->
|
||||
emoji.base.joinToString("") { String(Character.toChars(it.toInt())) } == unicode
|
||||
}
|
||||
|
|
@ -280,7 +326,8 @@ class EmojiImpl {
|
|||
}
|
||||
|
||||
fun codepointIsEmoji(codepoint: Int): Boolean {
|
||||
return metadata.any { group ->
|
||||
val currentMetadata = metadata ?: return false
|
||||
return currentMetadata.any { group ->
|
||||
group.emoji.any { emoji ->
|
||||
emoji.base.contains(codepoint.toLong()) || emoji.alternates.any { alternate ->
|
||||
alternate.contains(codepoint.toLong())
|
||||
|
|
@ -288,8 +335,14 @@ class EmojiImpl {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
metadata = initMetadata(RevoltApplication.instance.applicationContext)
|
||||
|
||||
fun invalidateCache() {
|
||||
serverEmojiCache.clear()
|
||||
serverListCache.clear()
|
||||
cachedPickerList = null
|
||||
cachedCategorySpans = null
|
||||
lastCacheKey = null
|
||||
}
|
||||
}
|
||||
|
||||
fun EmojiImpl(): EmojiRepository = EmojiRepository
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
package chat.revolt.internals
|
||||
|
||||
import android.content.Context
|
||||
import chat.revolt.RevoltApplication
|
||||
import chat.revolt.api.RevoltJson
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@Serializable
|
||||
data class EmojiUsageData(
|
||||
val usageCount: Map<String, Int> = emptyMap(),
|
||||
val lastUsed: Map<String, Long> = emptyMap()
|
||||
)
|
||||
|
||||
object EmojiUsageTracker {
|
||||
private const val PREFS_NAME = "emoji_usage"
|
||||
private const val KEY_DATA = "usage_data"
|
||||
|
||||
private var usageCount = mutableMapOf<String, Int>()
|
||||
private var lastUsed = mutableMapOf<String, Long>()
|
||||
|
||||
init {
|
||||
loadFromStorage()
|
||||
}
|
||||
|
||||
fun recordUsage(emoji: String) {
|
||||
usageCount[emoji] = (usageCount[emoji] ?: 0) + 1
|
||||
lastUsed[emoji] = System.currentTimeMillis()
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
fun getRecentlyUsed(limit: Int = 12): List<String> {
|
||||
if (lastUsed.isEmpty()) return emptyList()
|
||||
|
||||
return lastUsed.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
}
|
||||
|
||||
fun getMostUsed(limit: Int = 12): List<String> {
|
||||
if (usageCount.isEmpty()) return emptyList()
|
||||
|
||||
return usageCount.entries
|
||||
.sortedWith(
|
||||
compareByDescending<Map.Entry<String, Int>> { it.value }
|
||||
.thenByDescending { lastUsed[it.key] ?: 0 }
|
||||
)
|
||||
.take(limit)
|
||||
.map { it.key }
|
||||
}
|
||||
|
||||
private fun loadFromStorage() {
|
||||
try {
|
||||
val prefs = RevoltApplication.instance.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val jsonData = prefs.getString(KEY_DATA, null) ?: return
|
||||
|
||||
val data = RevoltJson.decodeFromString<EmojiUsageData>(jsonData)
|
||||
usageCount.putAll(data.usageCount)
|
||||
lastUsed.putAll(data.lastUsed)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToStorage() {
|
||||
try {
|
||||
val data = EmojiUsageData(
|
||||
usageCount = usageCount.toMap(),
|
||||
lastUsed = lastUsed.toMap()
|
||||
)
|
||||
val jsonData = RevoltJson.encodeToString(data)
|
||||
|
||||
val prefs = RevoltApplication.instance.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit()
|
||||
.putString(KEY_DATA, jsonData)
|
||||
.apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ import chat.revolt.composables.generic.RemoteImage
|
|||
import chat.revolt.composables.generic.UserAvatar
|
||||
import chat.revolt.composables.markdown.Annotations
|
||||
import chat.revolt.composables.utils.detectTapGesturesConditionalConsume
|
||||
import chat.revolt.markdown.jbm.RSMElementTypes
|
||||
import chat.revolt.ui.theme.FragmentMono
|
||||
import chat.revolt.ui.theme.isThemeDark
|
||||
import dev.snipme.highlights.Highlights
|
||||
|
|
@ -107,6 +108,7 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) {
|
|||
URL("URL", true),
|
||||
UserMention("UserMention", true),
|
||||
ChannelMention("ChannelMention", true),
|
||||
Spoiler("Spoiler", true),
|
||||
RoleMention("RoleMention", false),
|
||||
CustomEmote("CustomEmote", true),
|
||||
Timestamp("Timestamp", false),
|
||||
|
|
@ -130,6 +132,8 @@ data class JBMarkdownTreeState(
|
|||
val sourceText: String = "",
|
||||
val listDepth: Int = 0,
|
||||
val fontSizeMultiplier: Float = 1f,
|
||||
val revealedSpoilers: Set<String> = emptySet(),
|
||||
val onSpoilerToggle: ((String) -> Unit)? = null,
|
||||
val linksClickable: Boolean = true,
|
||||
val currentServer: String? = null,
|
||||
val embedded: Boolean = false,
|
||||
|
|
@ -150,6 +154,7 @@ val avatarPadding = 2.dp
|
|||
@JBM
|
||||
fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
|
||||
var tree by remember { mutableStateOf(JBMApi.parse(content)) }
|
||||
var revealedSpoilers by remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
LaunchedEffect(content) {
|
||||
tree = JBMApi.parse(content)
|
||||
|
|
@ -158,6 +163,14 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
|
|||
CompositionLocalProvider(
|
||||
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
|
||||
sourceText = content,
|
||||
revealedSpoilers = revealedSpoilers,
|
||||
onSpoilerToggle = { spoilerId ->
|
||||
revealedSpoilers = if (revealedSpoilers.contains(spoilerId)) {
|
||||
revealedSpoilers - spoilerId
|
||||
} else {
|
||||
revealedSpoilers + spoilerId
|
||||
}
|
||||
},
|
||||
colors = JBMColors(
|
||||
clickable = MaterialTheme.colorScheme.primary,
|
||||
clickableBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
|
||||
|
|
@ -183,14 +196,22 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun annotateText(
|
||||
state: JBMarkdownTreeState,
|
||||
node: ASTNode
|
||||
node: ASTNode,
|
||||
revealedSpoilers: Set<String> = emptySet()
|
||||
): AnnotatedString {
|
||||
val surfaceContainer = MaterialTheme.colorScheme.surfaceContainer
|
||||
val onSurface = MaterialTheme.colorScheme.onSurface
|
||||
val contentColor = LocalContentColor.current
|
||||
val sourceText = state.sourceText
|
||||
|
||||
return try {
|
||||
buildAnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
if (node.type.toString().contains("SPOILER", ignoreCase = true) ||
|
||||
sourceText.contains("||")) {
|
||||
android.util.Log.d("JBMRenderer", "Processing node: ${node.type}, text contains spoilers: ${sourceText.contains("||")}")
|
||||
}
|
||||
when (node.type) {
|
||||
MarkdownTokenTypes.TEXT -> {
|
||||
val source = if (state.embedded) {
|
||||
|
|
@ -207,7 +228,7 @@ private fun annotateText(
|
|||
if (userId == contents || !userId.isUlid()) {
|
||||
// Invalid user mention. Append as if it were regular text.
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
} else {
|
||||
pushStringAnnotation(
|
||||
|
|
@ -238,7 +259,7 @@ private fun annotateText(
|
|||
if (channelId == contents || !channelId.isUlid()) {
|
||||
// Invalid channel mention. Append as if it were regular text.
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
} else {
|
||||
pushStringAnnotation(
|
||||
|
|
@ -265,7 +286,7 @@ private fun annotateText(
|
|||
if (roleId == contents || !roleId.isUlid() || state.currentServer == null) {
|
||||
// Invalid role mention. Append as if it were regular text.
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
} else {
|
||||
val server = RevoltAPI.serverCache[state.currentServer]
|
||||
|
|
@ -309,7 +330,7 @@ private fun annotateText(
|
|||
if (emoteId == contents || !emoteId.isUlid()) {
|
||||
// Invalid custom emote. Append as if it were regular text.
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
} else {
|
||||
pushStringAnnotation(
|
||||
|
|
@ -321,6 +342,30 @@ private fun annotateText(
|
|||
}
|
||||
}
|
||||
|
||||
RSMElementTypes.SPOILER -> {
|
||||
android.util.Log.d("JBMRenderer", "Found SPOILER node with ${node.children.size} children")
|
||||
val spoilerContent = node.children.joinToString("") { it.getTextInNode(sourceText).toString() }
|
||||
val spoilerId = "spoiler_${System.identityHashCode(node)}"
|
||||
val isRevealed = revealedSpoilers.contains(spoilerId)
|
||||
android.util.Log.d("JBMRenderer", "Spoiler content: '$spoilerContent', ID: $spoilerId, revealed: $isRevealed")
|
||||
|
||||
pushStringAnnotation(
|
||||
tag = JBMAnnotations.Spoiler.tag,
|
||||
annotation = spoilerId
|
||||
)
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = if (isRevealed) surfaceContainer else onSurface,
|
||||
color = if (isRevealed) contentColor else onSurface
|
||||
)
|
||||
) {
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
pop()
|
||||
}
|
||||
|
||||
MarkdownTokenTypes.ATX_HEADER -> {
|
||||
// Do not need to do anything
|
||||
}
|
||||
|
|
@ -332,7 +377,7 @@ private fun annotateText(
|
|||
MarkdownElementTypes.ATX_5,
|
||||
MarkdownElementTypes.ATX_6 -> {
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,7 +386,7 @@ private fun annotateText(
|
|||
// Skip the first child and the last child
|
||||
// because they are the asterisk characters
|
||||
for (child in node.children.subList(1, node.children.size - 1)) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -351,17 +396,21 @@ private fun annotateText(
|
|||
// Skip the first two children and the last two children
|
||||
// because they are the asterisk characters
|
||||
for (child in node.children.subList(2, node.children.size - 2)) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GFMElementTypes.STRIKETHROUGH -> {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
// Skip the first two children and the last two children
|
||||
// because they are the tilde characters
|
||||
for (child in node.children.subList(2, node.children.size - 2)) {
|
||||
append(annotateText(state, child))
|
||||
// More defensive children handling
|
||||
val childrenToProcess = if (node.children.size >= 4) {
|
||||
node.children.subList(2, node.children.size - 2)
|
||||
} else {
|
||||
node.children
|
||||
}
|
||||
for (child in childrenToProcess) {
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -377,7 +426,7 @@ private fun annotateText(
|
|||
removeItemCount,
|
||||
node.children.size - removeItemCount
|
||||
)) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -396,7 +445,7 @@ private fun annotateText(
|
|||
MarkdownElementTypes.ORDERED_LIST,
|
||||
MarkdownElementTypes.LIST_ITEM -> {
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -409,18 +458,36 @@ private fun annotateText(
|
|||
append(" ")
|
||||
}
|
||||
|
||||
MarkdownElementTypes.PARAGRAPH,
|
||||
MarkdownElementTypes.PARAGRAPH -> {
|
||||
val paragraphText = node.getTextInNode(sourceText).toString()
|
||||
if (paragraphText.contains("||")) {
|
||||
android.util.Log.d("JBMRenderer", "Paragraph contains spoilers: '$paragraphText'")
|
||||
val spoilerRegex = Regex("\\|\\|(.+?)\\|\\|")
|
||||
if (spoilerRegex.containsMatchIn(paragraphText)) {
|
||||
processSpoilerParagraph(paragraphText, state, revealedSpoilers, surfaceContainer, onSurface, contentColor)
|
||||
} else {
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownElementTypes.HTML_BLOCK,
|
||||
MarkdownTokenTypes.HTML_TAG -> {
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
|
||||
MarkdownTokenTypes.ATX_CONTENT -> {
|
||||
// Drop WHITE_SPACE children at the start
|
||||
for (child in node.children.dropWhile { it.type == MarkdownTokenTypes.WHITE_SPACE }) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -493,30 +560,67 @@ private fun annotateText(
|
|||
append("[${node.type.name}]{\n")
|
||||
}
|
||||
for (child in node.children) {
|
||||
append(annotateText(state, child))
|
||||
append(annotateText(state, child, revealedSpoilers))
|
||||
}
|
||||
withStyle(SpanStyle(color = Color.Cyan)) {
|
||||
append("\n}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color(0xFFFF0000), background = Color(0xFF000000))) {
|
||||
append("[${node.type.name}] Error: ${e.message}")
|
||||
}
|
||||
|
||||
Log.e("JBMRenderer", "Error rendering node: ${node.type.name}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.Builder.processSpoilerParagraph(
|
||||
text: String,
|
||||
state: JBMarkdownTreeState,
|
||||
revealedSpoilers: Set<String>,
|
||||
surfaceContainer: Color,
|
||||
onSurface: Color,
|
||||
contentColor: Color
|
||||
) {
|
||||
val spoilerRegex = Regex("\\|\\|(.+?)\\|\\|")
|
||||
var lastIndex = 0
|
||||
|
||||
android.util.Log.d("JBMRenderer", "Processing spoiler paragraph: '$text'")
|
||||
|
||||
for (match in spoilerRegex.findAll(text)) {
|
||||
android.util.Log.d("JBMRenderer", "Found spoiler match: '${match.value}'")
|
||||
|
||||
// Append text before spoiler
|
||||
append(text.substring(lastIndex, match.range.first))
|
||||
|
||||
// Handle spoiler
|
||||
val spoilerContent = match.groupValues[1]
|
||||
val spoilerId = "spoiler_${match.range.first}_${spoilerContent.hashCode()}"
|
||||
val isRevealed = revealedSpoilers.contains(spoilerId)
|
||||
|
||||
android.util.Log.d("JBMRenderer", "Spoiler content: '$spoilerContent', revealed: $isRevealed")
|
||||
|
||||
pushStringAnnotation(
|
||||
tag = JBMAnnotations.Spoiler.tag,
|
||||
annotation = spoilerId
|
||||
)
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
background = if (isRevealed) surfaceContainer else onSurface,
|
||||
color = if (isRevealed) contentColor else onSurface
|
||||
)
|
||||
) {
|
||||
append(spoilerContent)
|
||||
}
|
||||
pop()
|
||||
|
||||
lastIndex = match.range.last + 1
|
||||
}
|
||||
|
||||
append(text.substring(lastIndex))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JBMText(node: ASTNode, modifier: Modifier) {
|
||||
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val mdState = LocalJBMarkdownTreeState.current
|
||||
val annotatedText = remember(node) { annotateText(mdState, node) }
|
||||
val annotatedText = annotateText(mdState, node, mdState.revealedSpoilers)
|
||||
val colours = MaterialTheme.colorScheme
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
|
@ -618,6 +722,11 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
|
|||
}
|
||||
return@handler true
|
||||
}
|
||||
|
||||
JBMAnnotations.Spoiler.tag -> {
|
||||
mdState.onSpoilerToggle?.invoke(item)
|
||||
return@handler true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,7 @@ object RSMElementTypes {
|
|||
|
||||
@JvmField
|
||||
val TIMESTAMP: IElementType = MarkdownElementType("TIMESTAMP")
|
||||
|
||||
@JvmField
|
||||
val SPOILER: IElementType = MarkdownElementType("SPOILER")
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import chat.revolt.markdown.jbm.sequentialparsers.ChannelMentionParser
|
|||
import chat.revolt.markdown.jbm.sequentialparsers.CustomEmoteParser
|
||||
import chat.revolt.markdown.jbm.sequentialparsers.MassMentionParser
|
||||
import chat.revolt.markdown.jbm.sequentialparsers.RoleMentionParser
|
||||
import chat.revolt.markdown.jbm.sequentialparsers.SpoilerParser
|
||||
import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||
|
|
@ -24,18 +25,19 @@ class RSMFlavourDescriptor : GFMFlavourDescriptor() {
|
|||
override val sequentialParserManager = object : SequentialParserManager() {
|
||||
override fun getParserSequence(): List<SequentialParser> {
|
||||
return listOf(
|
||||
UserMentionParser(),
|
||||
ChannelMentionParser(),
|
||||
RoleMentionParser(),
|
||||
MassMentionParser(),
|
||||
CustomEmoteParser(),
|
||||
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
|
||||
BacktickParser(),
|
||||
MathParser(),
|
||||
ImageParser(),
|
||||
InlineLinkParser(),
|
||||
ReferenceLinkParser(),
|
||||
EmphasisLikeParser(EmphStrongDelimiterParser(), StrikeThroughDelimiterParser())
|
||||
EmphasisLikeParser(EmphStrongDelimiterParser(), StrikeThroughDelimiterParser()),
|
||||
UserMentionParser(),
|
||||
ChannelMentionParser(),
|
||||
RoleMentionParser(),
|
||||
MassMentionParser(),
|
||||
CustomEmoteParser(),
|
||||
SpoilerParser()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
package chat.revolt.markdown.jbm.sequentialparsers
|
||||
|
||||
import chat.revolt.markdown.jbm.RSMElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class SpoilerParser : SequentialParser {
|
||||
override fun parse(
|
||||
tokens: TokensCache,
|
||||
rangesToGlue: List<IntRange>
|
||||
): SequentialParser.ParsingResult {
|
||||
android.util.Log.d("SpoilerParser", "SpoilerParser.parse() called with ${rangesToGlue.size} ranges")
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
android.util.Log.d("SpoilerParser", "Processing token: ${iterator.type}, text: '${iterator.toString()}'")
|
||||
|
||||
if (iterator.type == MarkdownTokenTypes.TEXT) {
|
||||
val text = iterator.toString()
|
||||
|
||||
if (text == "||" || text.startsWith("||") || text.contains("||")) {
|
||||
android.util.Log.d("SpoilerParser", "Found potential spoiler start: '$text'")
|
||||
|
||||
val start = iterator.index
|
||||
var searchIterator = iterator.advance()
|
||||
var foundContent = false
|
||||
var foundEnd = false
|
||||
|
||||
while (searchIterator.type != null) {
|
||||
val searchText = searchIterator.toString()
|
||||
android.util.Log.d("SpoilerParser", "Scanning: '${searchText}' (${searchIterator.type})")
|
||||
|
||||
if (searchText == "||" || searchText.endsWith("||") || searchText.contains("||")) {
|
||||
android.util.Log.d("SpoilerParser", "Found potential spoiler end: '$searchText'")
|
||||
foundEnd = true
|
||||
|
||||
result.withNode(
|
||||
SequentialParser.Node(
|
||||
start..searchIterator.index,
|
||||
RSMElementTypes.SPOILER
|
||||
)
|
||||
)
|
||||
android.util.Log.d("SpoilerParser", "Created SPOILER node from $start to ${searchIterator.index}")
|
||||
iterator = searchIterator.advance()
|
||||
break
|
||||
}
|
||||
|
||||
if (searchIterator.type == MarkdownTokenTypes.TEXT || searchIterator.type.toString() == "WHITE_SPACE") {
|
||||
foundContent = true
|
||||
}
|
||||
|
||||
searchIterator = searchIterator.advance()
|
||||
}
|
||||
|
||||
if (foundEnd) {
|
||||
continue
|
||||
} else {
|
||||
android.util.Log.d("SpoilerParser", "No closing || found")
|
||||
}
|
||||
}
|
||||
}
|
||||
delegateIndices.put(iterator.index)
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue