feat(stendal): initial implementation/switch

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-02-12 02:11:59 +01:00
parent ff8eca0863
commit 17d44622cc
44 changed files with 1146 additions and 1464 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "app/src/main/cpp/external/cmark"]
path = app/src/main/cpp/external/cmark
url = https://github.com/commonmark/cmark

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

View File

@ -2,5 +2,6 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/external/cmark" vcs="Git" />
</component> </component>
</project> </project>

View File

@ -245,9 +245,6 @@ dependencies {
// JDK Desugaring - polyfill for new Java APIs // JDK Desugaring - polyfill for new Java APIs
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
// Markdown
implementation "com.github.discord:SimpleAST:2.7.0"
// AndroidX Media3 w/ ExoPlayer // AndroidX Media3 w/ ExoPlayer
implementation "androidx.media3:media3-exoplayer:$media3_version" implementation "androidx.media3:media3-exoplayer:$media3_version"
implementation "androidx.media3:media3-exoplayer-hls:$media3_version" implementation "androidx.media3:media3-exoplayer-hls:$media3_version"

View File

@ -12,7 +12,8 @@ cmake_minimum_required(VERSION 3.22.1)
# build script scope). # build script scope).
project("revolt") project("revolt")
set(LIB_NAME_ACCESS_CONTROL "pipebomb") # Compile cmark in external/cmark
add_subdirectory(external/cmark)
# Creates and names a library, sets it as either STATIC # Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code. # or SHARED, and provides the relative paths to its source code.
@ -27,15 +28,16 @@ set(LIB_NAME_ACCESS_CONTROL "pipebomb")
# System.loadLibrary() and pass the name of the library defined here; # System.loadLibrary() and pass the name of the library defined here;
# for GameActivity/NativeActivity derived applications, the same library name must be # for GameActivity/NativeActivity derived applications, the same library name must be
# used in the AndroidManifest.xml file. # used in the AndroidManifest.xml file.
add_library(${LIB_NAME_ACCESS_CONTROL} SHARED add_library(stendal SHARED
# List C/C++ source files with relative paths to this CMakeLists.txt. # List C/C++ source files with relative paths to this CMakeLists.txt.
lib${LIB_NAME_ACCESS_CONTROL}/${LIB_NAME_ACCESS_CONTROL}.cpp stendal/stendal.cpp
) )
# Specifies libraries CMake should link to your target library. You # Specifies libraries CMake should link to your target library. You
# can link libraries from various origins, such as libraries defined in this # can link libraries from various origins, such as libraries defined in this
# build script, prebuilt third-party libraries, or Android system libraries. # build script, prebuilt third-party libraries, or Android system libraries.
target_link_libraries(${LIB_NAME_ACCESS_CONTROL} target_link_libraries(stendal
# List libraries link to the target library # List libraries link to the target library
cmark
android android
log) log)

0
app/src/main/cpp/external/.gitkeep vendored Normal file
View File

1
app/src/main/cpp/external/cmark vendored Submodule

@ -0,0 +1 @@
Subproject commit 3337a30715a641274a5a14aa167d6e51ba4066c0

View File

@ -1,25 +0,0 @@
#include <android/log.h>
#include <jni.h>
#include <string.h>
int hardCrashCounter = 0;
extern "C"
JNIEXPORT void JNICALL
Java_chat_revolt_ndk_Pipebomb_incrementHardCrashCounter(JNIEnv *env, jobject thiz) {
hardCrashCounter++;
}
extern "C"
JNIEXPORT void JNICALL
Java_chat_revolt_ndk_Pipebomb_doHardCrash(JNIEnv *env,
jobject thiz) {
int *p = nullptr;
*p = 0;
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_chat_revolt_ndk_Pipebomb_checkHardCrash(JNIEnv *env, jobject thiz) {
return hardCrashCounter > 3;
}

View File

@ -0,0 +1,167 @@
#include <android/log.h>
#include <jni.h>
#include <string>
#include <cmark.h>
#define TAG "Stendal"
#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"
namespace Stendal {
jclass arrayListClass = nullptr;
jmethodID constructArrayListMethod = nullptr;
jmethodID addArrayListMethod = nullptr;
jclass astNodeClass = nullptr;
jclass integerWrapperClass = nullptr;
jmethodID astNodeConstructor = nullptr;
jmethodID integerWrapperConstructor = nullptr;
jclass booleanWrapperClass = nullptr;
jmethodID booleanWrapperConstructor = nullptr;
inline bool string_starts_with(std::string const &value, std::string const &prefix) {
return value.rfind(prefix, 0) == 0;
}
inline bool string_ends_with(std::string const &value, std::string const &suffix) {
if (suffix.size() > value.size()) return false;
return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin());
}
inline bool paragraph_is_math(std::string const &value) {
return string_starts_with(value, "$") && string_ends_with(value, "$");
}
void init(JNIEnv *env) {
jclass localArrayListClass = env->FindClass("java/util/ArrayList");
arrayListClass = (jclass) env->NewGlobalRef(localArrayListClass);
constructArrayListMethod = env->GetMethodID(localArrayListClass, "<init>", "(I)V");
addArrayListMethod = env->GetMethodID(localArrayListClass, "add", "(Ljava/lang/Object;)Z");
jclass localAstNodeClass = env->FindClass("chat/revolt/ndk/AstNode");
astNodeClass = (jclass) env->NewGlobalRef(localAstNodeClass);
astNodeConstructor = env->GetMethodID(localAstNodeClass, "<init>",
STENDAL_ASTNODE_CONSTRUCTOR_SIGNATURE);
jclass localIntegerWrapperClass = env->FindClass("java/lang/Integer");
integerWrapperClass = (jclass) env->NewGlobalRef(localIntegerWrapperClass);
integerWrapperConstructor = env->GetMethodID(localIntegerWrapperClass, "<init>", "(I)V");
jclass localBooleanWrapperClass = env->FindClass("java/lang/Boolean");
booleanWrapperClass = (jclass) env->NewGlobalRef(localBooleanWrapperClass);
booleanWrapperConstructor = env->GetMethodID(localBooleanWrapperClass, "<init>", "(Z)V");
}
jobject node_instance(JNIEnv *env, cmark_node *node, jobject children) {
jstring typeStr = env->NewStringUTF(cmark_node_get_type_string(node));
jstring literalStr = env->NewStringUTF(cmark_node_get_literal(node));
jstring actionStr = env->NewStringUTF(cmark_node_get_url(node));
jobject headingLevelIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_heading_level(
node));
jobject listTypeIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_list_type(node));
jobject delimiterTypeIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_list_delim(
node));
jobject startIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_list_start(node));
jobject tightBoln = env->NewObject(Stendal::booleanWrapperClass,
Stendal::booleanWrapperConstructor,
(bool) cmark_node_get_list_tight(node));
jstring fenceStr = env->NewStringUTF(cmark_node_get_fence_info(node));
jstring titleStr = env->NewStringUTF(cmark_node_get_title(node));
jstring onEnterStr = env->NewStringUTF(cmark_node_get_on_enter(node));
jstring onExitStr = env->NewStringUTF(cmark_node_get_on_exit(node));
jobject startLineIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_start_line(node));
jobject endLineIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_end_line(node));
jobject startColumnIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_start_column(
node));
jobject endColumnIntg = env->NewObject(Stendal::integerWrapperClass,
Stendal::integerWrapperConstructor,
(int) cmark_node_get_end_column(node));
jobject inst = env->NewObject(Stendal::astNodeClass, Stendal::astNodeConstructor,
(int) cmark_node_get_type(node),
typeStr, children,
literalStr, actionStr, headingLevelIntg,
listTypeIntg, delimiterTypeIntg, startIntg, tightBoln,
fenceStr, titleStr, onEnterStr, onExitStr, startLineIntg,
endLineIntg,
startColumnIntg, endColumnIntg);
env->DeleteLocalRef(typeStr);
env->DeleteLocalRef(literalStr);
env->DeleteLocalRef(actionStr);
env->DeleteLocalRef(headingLevelIntg);
env->DeleteLocalRef(listTypeIntg);
env->DeleteLocalRef(delimiterTypeIntg);
env->DeleteLocalRef(startIntg);
env->DeleteLocalRef(tightBoln);
env->DeleteLocalRef(fenceStr);
env->DeleteLocalRef(titleStr);
env->DeleteLocalRef(onEnterStr);
env->DeleteLocalRef(onExitStr);
env->DeleteLocalRef(startLineIntg);
env->DeleteLocalRef(endLineIntg);
env->DeleteLocalRef(startColumnIntg);
env->DeleteLocalRef(endColumnIntg);
return inst;
}
jobject collect_nodes(JNIEnv *env, cmark_node *doc) {
std::vector<std::pair<cmark_node *, jobject>> children;
{
cmark_node *child = cmark_node_first_child(doc);
while (child) {
children.push_back(std::pair(child, Stendal::collect_nodes(env, child)));
child = cmark_node_next(child);
}
}
jobject list = env->NewObject(Stendal::arrayListClass, Stendal::constructArrayListMethod,
(int) children.size());
for (auto child: children) {
jobject inst = Stendal::node_instance(env, child.first, child.second);
env->DeleteLocalRef(child.second);
env->CallBooleanMethod(list, Stendal::addArrayListMethod, inst);
env->DeleteLocalRef(inst);
}
return list;
}
}
extern "C"
JNIEXPORT void JNICALL
Java_chat_revolt_ndk_Stendal_init(JNIEnv *env, jobject thiz) {
Stendal::init(env);
}
extern "C" JNIEXPORT jobject JNICALL
Java_chat_revolt_ndk_Stendal_render(JNIEnv *env, jobject thiz, jstring input) {
const char *inputStr = env->GetStringUTFChars(input, nullptr);
cmark_node *doc = cmark_parse_document(inputStr, strlen(inputStr),
CMARK_OPT_DEFAULT | CMARK_OPT_HARDBREAKS |
CMARK_OPT_VALIDATE_UTF8);
jobject nodes = Stendal::collect_nodes(env, doc);
jobject inst = Stendal::node_instance(env, doc, nodes);
cmark_node_free(doc);
env->ReleaseStringUTFChars(input, inputStr);
return inst;
}

View File

@ -1,7 +1,8 @@
package chat.revolt.api.routes.microservices.january package chat.revolt.api.routes.microservices.january
import chat.revolt.api.REVOLT_JANUARY import chat.revolt.api.REVOLT_JANUARY
import java.net.URLEncoder
fun asJanuaryProxyUrl(url: String): String { fun asJanuaryProxyUrl(url: String): String {
return "$REVOLT_JANUARY/proxy?url=$url" return "$REVOLT_JANUARY/proxy?url=${URLEncoder.encode(url, "utf-8")}"
} }

View File

@ -9,17 +9,6 @@ import chat.revolt.api.internals.SpecialUsers
annotation class FeatureFlag(val name: String) annotation class FeatureFlag(val name: String)
annotation class Treatment(val description: String) annotation class Treatment(val description: String)
@FeatureFlag("ClosedBetaAccessControl")
sealed class ClosedBetaAccessControlVariates {
@Treatment(
"Restrict access to the app to users that meet certain or all criteria (implementation-specific)"
)
data class Restricted(val predicate: () -> Boolean) : ClosedBetaAccessControlVariates()
@Treatment("Allow access to the app to all users")
data object Unrestricted : ClosedBetaAccessControlVariates()
}
@FeatureFlag("LabsAccessControl") @FeatureFlag("LabsAccessControl")
sealed class LabsAccessControlVariates { sealed class LabsAccessControlVariates {
@Treatment( @Treatment(
@ -29,13 +18,6 @@ sealed class LabsAccessControlVariates {
} }
object FeatureFlags { object FeatureFlags {
@FeatureFlag("ClosedBetaAccessControl")
var closedBetaAccessControl by mutableStateOf<ClosedBetaAccessControlVariates>(
ClosedBetaAccessControlVariates.Restricted {
RevoltAPI.channelCache.containsKey("01H7X2KRB0CA4QDSMB4N7WGERF")
}
)
@FeatureFlag("LabsAccessControl") @FeatureFlag("LabsAccessControl")
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>( var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
LabsAccessControlVariates.Restricted { LabsAccessControlVariates.Restricted {

View File

@ -28,9 +28,9 @@ import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.solidColor import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
import chat.revolt.api.schemas.Embed import chat.revolt.api.schemas.Embed
import chat.revolt.api.schemas.Embed as EmbedSchema
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.generic.UIMarkdown import chat.revolt.components.markdown.RichMarkdown
import chat.revolt.api.schemas.Embed as EmbedSchema
@Composable @Composable
fun RegularEmbed( fun RegularEmbed(
@ -100,8 +100,8 @@ fun RegularEmbed(
// Description // Description
embed.description?.let { embed.description?.let {
UIMarkdown( RichMarkdown(
text = it, input = it,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
} }

View File

@ -3,16 +3,7 @@ package chat.revolt.components.chat
import android.content.Intent import android.content.Intent
import android.icu.text.DateFormat import android.icu.text.DateFormat
import android.net.Uri import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.text.Selection
import android.text.SpannableStringBuilder
import android.text.SpannedString
import android.text.TextUtils
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -43,13 +34,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -57,9 +48,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import androidx.core.text.toSpannable
import chat.revolt.R import chat.revolt.R
import chat.revolt.activities.media.ImageViewActivity import chat.revolt.activities.media.ImageViewActivity
import chat.revolt.activities.media.VideoViewActivity import chat.revolt.activities.media.VideoViewActivity
@ -79,7 +67,8 @@ import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.generic.UserAvatar import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.generic.UserAvatarWidthPlaceholder import chat.revolt.components.generic.UserAvatarWidthPlaceholder
import chat.revolt.internals.markdown.LongClickableSpan import chat.revolt.components.markdown.LocalMarkdownTreeConfig
import chat.revolt.components.markdown.RichMarkdown
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import chat.revolt.api.schemas.Message as MessageSchema import chat.revolt.api.schemas.Message as MessageSchema
@ -172,8 +161,6 @@ fun formatLongAsTime(time: Long): String {
@Composable @Composable
fun Message( fun Message(
message: MessageSchema, message: MessageSchema,
truncate: Boolean = false,
parse: (MessageSchema) -> SpannableStringBuilder = { SpannableStringBuilder(it.content) },
onMessageContextMenu: () -> Unit = {}, onMessageContextMenu: () -> Unit = {},
onAvatarClick: () -> Unit = {}, onAvatarClick: () -> Unit = {},
onNameClick: (() -> Unit)? = null, onNameClick: (() -> Unit)? = null,
@ -183,7 +170,6 @@ fun Message(
) { ) {
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator() val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current val context = LocalContext.current
val contentColor = LocalContentColor.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -326,94 +312,13 @@ fun Message(
message.content?.let { message.content?.let {
if (message.content.isBlank()) return@let // if only an attachment is sent if (message.content.isBlank()) return@let // if only an attachment is sent
AndroidView( CompositionLocalProvider(
factory = { ctx -> LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
androidx.appcompat.widget.AppCompatTextView(ctx).apply { currentServer = RevoltAPI.channelCache[message.channel]?.server
maxLines = if (truncate) 1 else Int.MAX_VALUE
ellipsize = TextUtils.TruncateAt.END
textSize = 16f
typeface = ResourcesCompat.getFont(ctx, R.font.inter)
setOnTouchListener(object : OnTouchListener {
private var longClickHandler: Handler? = null
private var isLongPressed = false
override fun onTouch(
widget: View?,
event: MotionEvent?
): Boolean {
if (longClickHandler == null) {
longClickHandler =
Handler(Looper.getMainLooper())
}
if (event == null) return false
val action = event.action
if (action == MotionEvent.ACTION_CANCEL) {
longClickHandler?.removeCallbacksAndMessages(
null
) )
}
if (action == MotionEvent.ACTION_UP ||
action == MotionEvent.ACTION_DOWN
) { ) {
var x = event.x.toInt() RichMarkdown(input = message.content)
var y = event.y.toInt()
x -= (widget as androidx.appcompat.widget.AppCompatTextView).totalPaddingLeft
y -= widget.totalPaddingTop
x += widget.scrollX
y += widget.scrollY
val spannedString = widget.text as SpannedString
val layout = widget.layout
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(
line,
x.toFloat()
)
val link = spannedString.getSpans(
off,
off,
LongClickableSpan::class.java
)
if (link.isNotEmpty()) {
if (action == MotionEvent.ACTION_UP) {
longClickHandler?.removeCallbacksAndMessages(
null
)
if (!isLongPressed) {
link[0].onClick(widget)
} }
isLongPressed = false
} else {
Selection.setSelection(
spannedString.toSpannable(),
spannedString.getSpanStart(link[0]),
spannedString.getSpanEnd(link[0])
)
longClickHandler?.postDelayed(
{
link[0].onLongClick(widget)
isLongPressed = true
},
250L
)
}
return true
}
}
return false
}
})
setTextColor(contentColor.toArgb())
}
},
update = {
it.text = parse(message)
}
)
} }
} }

View File

@ -28,7 +28,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.Message
import chat.revolt.components.generic.UIMarkdown import chat.revolt.components.markdown.RichMarkdown
enum class SystemMessageType(val type: String) { enum class SystemMessageType(val type: String) {
CHANNEL_OWNERSHIP_CHANGED("channel_ownership_changed"), CHANNEL_OWNERSHIP_CHANGED("channel_ownership_changed"),
@ -78,7 +78,7 @@ fun SystemMessage(message: Message) {
when (systemMessageType) { when (systemMessageType) {
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> { SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_ownership_changed, R.string.system_message_ownership_changed,
message.system.from.mention(), message.system.from.mention(),
@ -88,7 +88,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.CHANNEL_ICON_CHANGED -> { SystemMessageType.CHANNEL_ICON_CHANGED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_channel_icon_changed, R.string.system_message_channel_icon_changed,
message.system.by.mention() message.system.by.mention()
@ -97,7 +97,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> { SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_channel_description_changed, R.string.system_message_channel_description_changed,
message.system.by.mention() message.system.by.mention()
@ -106,7 +106,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.CHANNEL_RENAMED -> { SystemMessageType.CHANNEL_RENAMED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_channel_renamed, R.string.system_message_channel_renamed,
message.system.by.mention(), message.system.by.mention(),
@ -116,7 +116,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.USER_REMOVE -> { SystemMessageType.USER_REMOVE -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_user_removed, R.string.system_message_user_removed,
message.system.by.mention(), message.system.by.mention(),
@ -126,7 +126,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.USER_ADDED -> { SystemMessageType.USER_ADDED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_user_added, R.string.system_message_user_added,
message.system.by.mention(), message.system.by.mention(),
@ -136,7 +136,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.USER_BANNED -> { SystemMessageType.USER_BANNED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_user_banned, R.string.system_message_user_banned,
message.system.id.mention() message.system.id.mention()
@ -145,7 +145,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.USER_KICKED -> { SystemMessageType.USER_KICKED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_user_kicked, R.string.system_message_user_kicked,
message.system.id.mention() message.system.id.mention()
@ -154,7 +154,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.USER_LEFT -> { SystemMessageType.USER_LEFT -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_user_left, R.string.system_message_user_left,
message.system.id.mention() message.system.id.mention()
@ -163,7 +163,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.USER_JOINED -> { SystemMessageType.USER_JOINED -> {
UIMarkdown( RichMarkdown(
stringResource( stringResource(
R.string.system_message_user_joined, R.string.system_message_user_joined,
message.system.id.mention() message.system.id.mention()
@ -172,7 +172,7 @@ fun SystemMessage(message: Message) {
} }
SystemMessageType.TEXT -> { SystemMessageType.TEXT -> {
message.system.content?.let { UIMarkdown(it) } message.system.content?.let { RichMarkdown(it) }
} }
} }
} }

View File

@ -1,113 +0,0 @@
package chat.revolt.components.generic
import android.text.TextUtils
import android.util.TypedValue
import android.view.ViewGroup
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.internals.markdown.LongClickLinkMovementMethod
import chat.revolt.internals.markdown.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule
import com.discord.simpleast.core.simple.SimpleMarkdownRules
import com.discord.simpleast.core.simple.SimpleRenderer
/**
* A Markdown rendering component for Markdown embedded in UI (e.g. in a button).
* @param text The text to render.
* @param fontSize The font size to use.
* @param modifier The modifier to apply to the rendered text. Will be applied to AndroidView and thus subject to AndroidView's limitations.
* @param maxLines The maximum number of lines to display. Text will always be ellipsized on overflow. Defaults to [Int.MAX_VALUE].
*/
@Composable
fun UIMarkdown(
text: String,
modifier: Modifier = Modifier,
fontSize: TextUnit = LocalTextStyle.current.fontSize,
maxLines: Int = Int.MAX_VALUE
) {
val context = LocalContext.current
val foregroundColor = LocalContentColor.current
val codeBlockColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
AndroidView(
factory = {
androidx.appcompat.widget.AppCompatTextView(it).apply {
ellipsize = TextUtils.TruncateAt.END
typeface = ResourcesCompat.getFont(it, R.font.inter)
setTextColor(foregroundColor.toArgb())
setMaxLines(maxLines)
setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.value)
movementMethod = LongClickLinkMovementMethod.instance
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
},
modifier = modifier,
update = {
val parser = MarkdownParser()
.addRules(
SimpleMarkdownRules.createEscapeRule()
)
.addRevoltRules(context)
.addRules(
createCodeRule(context, codeBlockColor.toArgb()),
createInlineCodeRule(context, codeBlockColor.toArgb())
)
.addRules(
SimpleMarkdownRules.createSimpleMarkdownRules(
includeEscapeRule = false
)
)
val spannableStringBuilder = SimpleRenderer.render(
source = text,
parser = parser,
initialState = MarkdownState(0),
renderContext = MarkdownContext(
memberMap = mapOf(),
userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id ?: "{this does not exist 🤫}"
},
emojiMap = RevoltAPI.emojiCache,
serverId = null,
useLargeEmojis = false
)
)
it.text = spannableStringBuilder
}
)
}
@Preview(showBackground = true)
@Composable
fun UIMarkdownPreview() {
UIMarkdown(
text = "Hello, **world**! <@01F1WKM5TK2V6KCZWR6DGBJDTZ> [link](https://google.com) `code`\n\n```kt\nfun main() {\n println(\"Hello, world!\")\n}\n```",
fontSize = 16.sp
)
}

View File

@ -0,0 +1,38 @@
package chat.revolt.components.markdown
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.ndk.AstNode
@Composable
fun MarkdownCodeBlock(node: AstNode, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.then(
if (node.startLine != 1) {
Modifier.padding(top = 8.dp)
} else {
Modifier
}
)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.padding(8.dp)
) {
Text(
text = node.text?.removeSuffix("\n") ?: "",
fontFamily = FontFamily(Font(R.font.jetbrainsmono_regular)),
)
}
}

View File

@ -0,0 +1,425 @@
package chat.revolt.components.markdown
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.height
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.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
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.unit.dp
import androidx.core.net.toUri
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.custom.fetchEmoji
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.utils.detectTapGesturesConditionalConsume
import chat.revolt.internals.resolveTimestamp
import chat.revolt.ndk.AstNode
import kotlinx.coroutines.launch
enum class Annotations(val tag: String, val clickable: Boolean) {
URL("URL", true),
UserMention("UserMention", true),
ChannelMention("ChannelMention", true),
CustomEmote("CustomEmote", true),
Timestamp("Timestamp", false)
}
object MarkdownTextRegularExpressions {
val Mention = Regex("<@([0-9A-Z]{26})>")
val Channel = Regex("<#([0-9A-Z]{26})>")
val CustomEmote = Regex(":([0-9A-Z]{26}):")
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@:%_+.~#?&/=]*)>?")
}
/**
* Visit the AST and its children and return an [AnnotatedString] with the appropriate annotations.
*/
@Composable
fun annotateText(node: AstNode): AnnotatedString {
return buildAnnotatedString {
when (node.stringType) {
"text" -> {
val text = node.text ?: ""
val mentions = MarkdownTextRegularExpressions.Mention.findAll(text)
val channels = MarkdownTextRegularExpressions.Channel.findAll(text)
val customEmotes = MarkdownTextRegularExpressions.CustomEmote.findAll(text)
val timestamps = MarkdownTextRegularExpressions.Timestamp.findAll(text)
val urls = MarkdownTextRegularExpressions.UrlFallback.findAll(text)
var lastIndex = 0
for (mention in mentions) {
append(text.substring(lastIndex, mention.range.first))
pushStringAnnotation(
tag = Annotations.UserMention.tag,
annotation = mention.groupValues[1]
)
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
color = MaterialTheme.colorScheme.primary,
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
)
)
val member = LocalMarkdownTreeConfig.current.currentServer?.let { serverId ->
RevoltAPI.members.getMember(serverId, mention.groupValues[1])
}
val content = member?.nickname?.let { nick -> "@$nick" }
?: RevoltAPI.userCache[mention.groupValues[1]]?.username?.let { username -> "@$username" }
?: "<@${mention.groupValues[1]}>"
append(content)
pop()
pop()
lastIndex = mention.range.last + 1
}
for (channel in channels) {
append(text.substring(lastIndex, channel.range.first))
pushStringAnnotation(
tag = Annotations.ChannelMention.tag,
annotation = channel.groupValues[1]
)
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
color = MaterialTheme.colorScheme.primary,
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
)
)
val content =
RevoltAPI.channelCache[channel.groupValues[1]]?.name?.let { chId -> "#$chId" }
?: "<#${channel.groupValues[1]}>"
append(content)
pop()
pop()
lastIndex = channel.range.last + 1
}
for (emote in customEmotes) {
append(text.substring(lastIndex, emote.range.first))
pushStringAnnotation(
tag = Annotations.CustomEmote.tag,
annotation = emote.groupValues[1]
)
appendInlineContent("CustomEmote", emote.groupValues[1])
pop()
lastIndex = emote.range.last + 1
}
for (timestamp in timestamps) {
append(text.substring(lastIndex, timestamp.range.first))
pushStringAnnotation(
tag = Annotations.Timestamp.tag,
annotation = timestamp.groupValues[1]
)
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
fontFamily = FontFamily(Font(R.font.jetbrainsmono_regular)),
background = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
)
)
append(
resolveTimestamp(
try {
timestamp.groupValues[1].toLong()
} catch (e: NumberFormatException) {
-1
},
timestamp.groupValues.getOrNull(2)
)
)
pop()
lastIndex = timestamp.range.last + 1
}
// Yes, cmark should handle this, but for gTLDs like .chat it doesn't.
// As a service with a .chat TLD, this is a problem. Duct tape fix, their fault.
for (url in urls) {
append(text.substring(lastIndex, url.range.first))
pushStringAnnotation(
tag = Annotations.URL.tag,
annotation = url.value
)
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
color = MaterialTheme.colorScheme.primary
)
)
append(url.value)
pop()
pop()
lastIndex = url.range.last + 1
}
append(text.substring(lastIndex, text.length))
}
"emph" -> {
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
fontStyle = FontStyle.Italic,
fontSynthesis = FontSynthesis.All
)
)
node.children?.forEach { append(annotateText(it)) }
pop()
}
"strong" -> {
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
fontWeight = FontWeight.Bold,
fontSynthesis = FontSynthesis.All
)
)
node.children?.forEach { append(annotateText(it)) }
pop()
}
"link" -> {
pushStringAnnotation(
tag = Annotations.URL.tag,
annotation = node.url ?: ""
)
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
color = MaterialTheme.colorScheme.primary
)
)
node.children?.forEach { append(annotateText(it)) }
pop()
pop()
}
"code" -> {
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
fontFamily = FontFamily(Font(R.font.jetbrainsmono_regular)),
fontSynthesis = FontSynthesis.All,
background = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
)
)
append(node.text ?: "")
pop()
}
"softbreak" -> {
append("\n")
}
else -> {
node.children?.forEach { append(annotateText(it)) }
}
}
}
}
@Composable
fun MarkdownText(textNode: AstNode, modifier: Modifier = Modifier) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val annotatedText = annotateText(textNode)
val context = LocalContext.current
val background = MaterialTheme.colorScheme.background
val scope = rememberCoroutineScope()
val markdownConfig = LocalMarkdownTreeConfig.current
val shouldConsumeTap = handler@{ offset: Int ->
Annotations.entries.filter { it.clickable }.map { it.tag }.forEach { tag ->
if (annotatedText.getStringAnnotations(
tag = tag,
start = offset,
end = offset
).isNotEmpty()
) {
return@handler true
}
}
return@handler false
}
val onClick = handler@{ offset: Int ->
if (markdownConfig.linksClickable) {
annotatedText.getStringAnnotations(
tag = Annotations.URL.tag,
start = offset,
end = offset
).firstOrNull()?.let { annotation ->
val url = annotation.item
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(background.toArgb())
.build()
)
.build()
customTab.launchUrl(context, url.toUri())
return@handler true
}
annotatedText.getStringAnnotations(
tag = Annotations.UserMention.tag,
start = offset,
end = offset
).firstOrNull()?.let { annotation ->
scope.launch {
ActionChannel.send(
Action.OpenUserSheet(
annotation.item,
markdownConfig.currentServer
)
)
}
return@handler true
}
annotatedText.getStringAnnotations(
tag = Annotations.ChannelMention.tag,
start = offset,
end = offset
).firstOrNull()?.let { annotation ->
scope.launch {
ActionChannel.send(Action.SwitchChannel(annotation.item))
}
return@handler true
}
annotatedText.getStringAnnotations(
tag = Annotations.CustomEmote.tag,
start = offset,
end = offset
).firstOrNull()?.let { annotation ->
scope.launch {
ActionChannel.send(Action.EmoteInfo(annotation.item))
}
return@handler true
}
}
false
}
val onLongClick = handler@{ offset: Int ->
if (markdownConfig.linksClickable) {
annotatedText.getStringAnnotations(
tag = Annotations.URL.tag,
start = offset,
end = offset
).firstOrNull()?.let { annotation ->
scope.launch {
ActionChannel.send(Action.LinkInfo(annotation.item))
}
return@handler true
}
}
false
}
Text(
text = annotatedText,
style = LocalTextStyle.current,
color = LocalContentColor.current,
onTextLayout = { layoutResult = it },
inlineContent = mapOf(
"CustomEmote" to InlineTextContent(
placeholder = Placeholder(
width = LocalTextStyle.current.fontSize * 1.5,
height = LocalTextStyle.current.fontSize * 1.5,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
),
) { id ->
val emote = RevoltAPI.emojiCache[id]
if (emote == null) {
scope.launch {
try {
RevoltAPI.emojiCache[id] = fetchEmoji(id)
} catch (e: Exception) {
// no-op
}
}
return@InlineTextContent
} else {
with(LocalDensity.current) {
RemoteImage(
url = "$REVOLT_FILES/emojis/${id}/emoji.gif",
description = emote.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.width((LocalTextStyle.current.fontSize * 1.5).toDp())
.height((LocalTextStyle.current.fontSize * 1.5).toDp())
)
}
}
}
),
modifier = modifier.pointerInput(onClick, onLongClick) {
detectTapGesturesConditionalConsume(
onTap = { pos ->
val index =
layoutResult?.getOffsetForPosition(pos)
?: return@detectTapGesturesConditionalConsume
onClick(index)
},
onLongPress = { pos ->
val index =
layoutResult?.getOffsetForPosition(pos)
?: return@detectTapGesturesConditionalConsume
onLongClick(index)
},
shouldConsumeTap = { pos ->
val index =
layoutResult?.getOffsetForPosition(pos)
?: return@detectTapGesturesConditionalConsume false
shouldConsumeTap(index)
}
)
}
)
}

View File

@ -0,0 +1,129 @@
package chat.revolt.components.markdown
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.api.internals.solidColor
import chat.revolt.ndk.AstNode
data class MarkdownTreeConfig(
val linksClickable: Boolean = true,
val currentServer: String? = null
)
val LocalMarkdownTreeConfig =
compositionLocalOf(structuralEqualityPolicy()) { MarkdownTreeConfig() }
@Composable
private fun Children(node: AstNode) {
node.children?.forEach { MarkdownTree(it) }
}
@Composable
fun MarkdownTree(node: AstNode) {
when (node.stringType) {
"heading" -> {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
fontWeight = FontWeight.Bold,
fontSize = when (node.level) {
1 -> 32.sp
2 -> 24.sp
3 -> 20.sp
4 -> 16.sp
5 -> 14.sp
else -> 12.sp
}
)
) {
if (node.startLine != 1) {
Box(Modifier.padding(top = 8.dp)) {
MarkdownText(node)
}
} else {
MarkdownText(node)
}
}
}
"paragraph" -> {
MarkdownText(
node,
modifier = Modifier
.then(
if (node.startLine != 1) {
Modifier.padding(top = 8.dp)
} else {
Modifier
}
)
)
}
"document" -> {
Children(node)
}
"text" -> {
MarkdownText(node)
}
"code_block" -> {
MarkdownCodeBlock(node)
}
"block_quote" -> {
Row(
modifier = Modifier
.padding(top = 4.dp, bottom = 4.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.fillMaxWidth()
.height(IntrinsicSize.Min)
) {
// Stripe at the left side of the blockquote
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(
Brush.solidColor(LocalContentColor.current)
)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Children(node)
}
}
}
else -> {
Children(node)
}
}
}

View File

@ -0,0 +1,13 @@
package chat.revolt.components.markdown
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.revolt.ndk.Stendal
@Composable
fun RichMarkdown(input: String, modifier: Modifier = Modifier) {
Column(modifier) {
MarkdownTree(node = Stendal.render(input))
}
}

View File

@ -0,0 +1,218 @@
package chat.revolt.components.utils
import androidx.compose.foundation.gestures.GestureCancellationException
import androidx.compose.foundation.gestures.PressGestureScope
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
// Copy-pasted from androidx.compose.foundation.gestures.TapGestureDetector.kt
// Available at https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/gesture/TapGestureDetector.kt;drc=0a141fd6fa614024c6d6d3deb8bf8b321eb1f597
// License: Apache 2.0 reproduced below
// Stated changes: - Renamed detectTapGestures to detectTapGesturesConditionalConsume.
// - Made gesture consumption conditional on the result of a provided lambda.
// - Added this comment.
// - Removed source code not relevant to use case.
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
private class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
/**
* Called when a gesture has been canceled.
*/
fun cancel() {
isCanceled = true
mutex.unlock()
}
/**
* Called when all pointers are up.
*/
fun release() {
isReleased = true
mutex.unlock()
}
/**
* Called when a new gesture has started.
*/
suspend fun reset() {
mutex.lock()
isReleased = false
isCanceled = false
}
override suspend fun awaitRelease() {
if (!tryAwaitRelease()) {
throw GestureCancellationException("The press gesture was canceled.")
}
}
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
mutex.unlock()
}
return isReleased
}
}
private suspend fun AwaitPointerEventScope.awaitSecondDown(
firstUp: PointerInputChange
): PointerInputChange? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
var change: PointerInputChange
// The second tap doesn't count if it happens before DoubleTapMinTime of the first tap
do {
change = awaitFirstDown()
} while (change.uptimeMillis < minUptime)
change
}
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
do {
val event = awaitPointerEvent()
event.changes.fastForEach { it.consume() }
} while (event.changes.fastAny { it.pressed })
}
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Boolean = { false }
suspend fun PointerInputScope.detectTapGesturesConditionalConsume(
onDoubleTap: ((Offset) -> Unit)? = null,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Boolean = NoPressGesture,
onTap: ((Offset) -> Unit)? = null,
shouldConsumeTap: ((Offset) -> Boolean)? = null
) = coroutineScope {
// special signal to indicate to the sending side that it shouldn't intercept and consume
// cancel/up events as we're only require down events
val pressScope = PressGestureScopeImpl(this@detectTapGesturesConditionalConsume)
awaitEachGesture {
val down = awaitFirstDown()
if (shouldConsumeTap?.invoke(down.position) == true) {
down.consume()
}
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
viewConfiguration.longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
var upOrCancel: PointerInputChange? = null
try {
// wait for first tap up or long press
upOrCancel = withTimeout(longPressTimeout) {
waitForUpOrCancellation()
}
if (upOrCancel == null) {
launch {
pressScope.cancel() // tap-up was canceled
}
} else {
if (shouldConsumeTap?.invoke(upOrCancel.position) == true) {
upOrCancel.consume()
}
launch {
pressScope.release()
}
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress?.invoke(down.position)
consumeUntilUp()
launch {
pressScope.release()
}
}
if (upOrCancel != null) {
// tap was successful.
if (onDoubleTap == null) {
onTap?.invoke(upOrCancel.position) // no need to check for double-tap.
} else {
// check for second tap
val secondDown = awaitSecondDown(upOrCancel)
if (secondDown == null) {
onTap?.invoke(upOrCancel.position) // no valid second tap started
} else {
// Second tap down detected
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) {
launch { pressScope.onPress(secondDown.position) }
}
try {
// Might have a long second press as the second tap
withTimeout(longPressTimeout) {
val secondUp = waitForUpOrCancellation()
if (secondUp != null) {
if (shouldConsumeTap?.invoke(secondUp.position) == true) {
secondUp.consume()
}
launch {
pressScope.release()
}
onDoubleTap(secondUp.position)
} else {
launch {
pressScope.cancel()
}
onTap?.invoke(upOrCancel.position)
}
}
} catch (e: PointerEventTimeoutCancellationException) {
// The first tap was valid, but the second tap is a long press.
// notify for the first tap
onTap?.invoke(upOrCancel.position)
// notify for the long press
onLongPress?.invoke(secondDown.position)
consumeUntilUp()
launch {
pressScope.release()
}
}
}
}
}
}
}

View File

@ -1,4 +1,4 @@
package chat.revolt.internals.extensions package chat.revolt.components.utils
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect

View File

@ -0,0 +1,33 @@
package chat.revolt.components.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.DpSize
@Composable
fun SizeMeasured(
viewToMeasure: @Composable () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (DpSize) -> Unit,
) {
SubcomposeLayout(modifier = modifier) { constraints ->
val measuredSize = subcompose("viewToMeasure") {
viewToMeasure()
}[0].measure(constraints)
.let {
DpSize(
width = it.width.toDp(),
height = it.height.toDp()
)
}
val contentPlaceable = subcompose("content") {
content(measuredSize)
}.firstOrNull()?.measure(constraints)
layout(contentPlaceable?.width ?: 0, contentPlaceable?.height ?: 0) {
contentPlaceable?.place(0, 0)
}
}
}

View File

@ -1,13 +1,13 @@
package chat.revolt.internals.markdown package chat.revolt.internals
import android.text.format.DateUtils import android.text.format.DateUtils
import android.util.Log import android.util.Log
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant import kotlinx.datetime.toJavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
fun resolveTimestamp(timestamp: Long, modifier: String? = null): String { fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
val normalisedModifier = modifier.orEmpty().removePrefix(":") val normalisedModifier = modifier.orEmpty().removePrefix(":")

View File

@ -1,135 +0,0 @@
package chat.revolt.internals.markdown
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.LeadingMarginSpan
import android.text.style.LineBackgroundSpan
import androidx.annotation.ColorInt
import com.discord.simpleast.core.node.Node
// Attribution:
// https://github.com/discord/SimpleAST/blob/567b61c51056cbdec39e839100690c576c26a4c6/app/src/main/java/com/discord/simpleast/sample/spans/BlockBackgroundNode.kt
// LICENSED UNDER THE APACHE LICENSE, VERSION 2.0
// Adapted for Revolt.
//
// Changes:
// - Fill and stroke colours are now parameters
// - Whether we are in a quote is no longer a boolean, but an integer with the quote depth
// - The left margin is now calculated based on the quote depth
/**
* Creates a block background for code sections.
*/
class BlockBackgroundNode<R>(
private val quoteDepth: Int,
private val fillColor: Int = Color.DKGRAY,
private val strokeColor: Int = Color.BLACK,
vararg children: Node<R>
) : Node.Parent<R>(*children) {
override fun render(builder: SpannableStringBuilder, renderContext: R) {
// Ensure the block we want to append starts on a newline.
ensureEndsWithNewline(builder)
val codeStartIndex = builder.length
super.render(builder, renderContext)
// BlockBackgroundSpan requires this to function
ensureEndsWithNewline(builder)
val backgroundSpan = BlockBackgroundSpan(
fillColor,
strokeColor,
strokeWidth = 2,
strokeRadius = 15,
leftMargin = 40 * quoteDepth
)
builder.setSpan(
backgroundSpan,
codeStartIndex,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
// Apply a leading margin to all lines in the block.
val leadingMarginSpan = LeadingMarginSpan.Standard(15)
builder.setSpan(
leadingMarginSpan,
codeStartIndex,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
private fun ensureEndsWithNewline(builder: SpannableStringBuilder) {
if (builder.isNotEmpty()) {
val lastChar = CharArray(6)
builder.getChars(builder.length - 1, builder.length, lastChar, 0)
if (lastChar[0] != '\n') {
builder.append('\n')
}
}
}
}
/**
* Computes the position of the paragraph on the screen and draws the desired background.
*/
class BlockBackgroundSpan(
@ColorInt fillColor: Int,
@ColorInt strokeColor: Int,
strokeWidth: Int,
strokeRadius: Int,
val leftMargin: Int
) : LineBackgroundSpan {
private val fillPaint = Paint().apply {
this.style = Paint.Style.FILL
this.color = fillColor
}
private val strokePaint = Paint().apply {
this.style = Paint.Style.STROKE
this.color = strokeColor
this.strokeWidth = strokeWidth.toFloat()
this.isAntiAlias = true
}
private val rect = RectF()
private val radius = strokeRadius.toFloat()
fun draw(canvas: Canvas) {
canvas.drawRoundRect(rect, radius, radius, fillPaint)
canvas.drawRoundRect(rect, radius, radius, strokePaint)
}
override fun drawBackground(
canvas: Canvas,
paint: Paint,
left: Int,
right: Int,
top: Int,
baseline: Int,
bottom: Int,
text: CharSequence,
start: Int,
end: Int,
lnum: Int
) {
if (text !is Spanned) return
if (text.getSpanStart(this) == start) {
rect.left = left.toFloat() + leftMargin
rect.top = top.toFloat()
}
if (text.getSpanEnd(this) == end) {
rect.right = right.toFloat()
rect.bottom = bottom.toFloat()
draw(canvas)
}
}
}

View File

@ -1,24 +0,0 @@
package chat.revolt.internals.markdown
import android.graphics.drawable.Drawable
import android.text.style.ImageSpan
import android.view.View
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
class EmoteSpan(drawable: Drawable) :
ImageSpan(drawable, ALIGN_BOTTOM)
class EmoteClickableSpan(private val emoteId: String) : LongClickableSpan() {
override fun onClick(widget: View) {
runBlocking(Dispatchers.IO) {
ActionChannel.send(Action.EmoteInfo(emoteId))
}
}
override fun onLongClick(view: View?) {
// no-op
}
}

View File

@ -1,86 +0,0 @@
package chat.revolt.internals.markdown
import android.content.Intent
import android.net.Uri
import android.text.TextPaint
import android.view.View
import androidx.browser.customtabs.CustomTabsIntent
import chat.revolt.activities.InviteActivity
import chat.revolt.api.REVOLT_APP
import chat.revolt.api.REVOLT_INVITES
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
class LinkSpan(private val url: String, private val drawBackground: Boolean = false) :
LongClickableSpan() {
override fun onClick(widget: View) {
val uri = Uri.parse(url)
// Intercept invite links
if (uri.host == Uri.parse(REVOLT_INVITES).host!! ||
(
uri.host?.endsWith(Uri.parse(REVOLT_APP).host!!) == true && uri.path?.startsWith(
"/invite"
) == true
)
) {
val intent = Intent(
widget.context,
InviteActivity::class.java
).setAction(Intent.ACTION_VIEW)
intent.data = uri
widget.context.startActivity(intent)
return
}
if (url.startsWith("revolt-android://link-action")) {
// parse action
val action = uri.pathSegments[0]
when (action) {
"user" -> {
val userId = uri.getQueryParameter("user")
val serverId = uri.getQueryParameter("server")
runBlocking(Dispatchers.IO) {
ActionChannel.send(Action.OpenUserSheet(userId!!, serverId))
}
}
"channel" -> {
val channelId = uri.getQueryParameter("channel")
runBlocking(Dispatchers.IO) {
ActionChannel.send(Action.SwitchChannel(channelId!!))
}
}
}
return
}
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
customTab.launchUrl(widget.context, Uri.parse(url))
}
override fun onLongClick(view: View?) {
runBlocking(Dispatchers.IO) {
ActionChannel.send(Action.LinkInfo(url))
}
}
override fun updateDrawState(ds: TextPaint) {
ds.color = ds.linkColor
ds.isUnderlineText = false
if (drawBackground) {
ds.bgColor = ds.linkColor and 0x33ffffff // 20% alpha
}
}
}

View File

@ -1,82 +0,0 @@
package chat.revolt.internals.markdown
import android.os.Handler
import android.os.Looper
import android.text.Selection
import android.text.Spannable
import android.text.method.LinkMovementMethod
import android.text.method.MovementMethod
import android.text.style.ClickableSpan
import android.view.MotionEvent
import android.view.View
import android.widget.TextView
abstract class LongClickableSpan : ClickableSpan() {
abstract fun onLongClick(view: View?)
}
// https://stackoverflow.com/a/63398843
class LongClickLinkMovementMethod : LinkMovementMethod() {
private var longClickHandler: Handler? = null
private var isLongPressed = false
override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean {
val action = event.action
if (action == MotionEvent.ACTION_CANCEL) {
longClickHandler?.removeCallbacksAndMessages(null)
}
if (action == MotionEvent.ACTION_UP ||
action == MotionEvent.ACTION_DOWN
) {
var x = event.x.toInt()
var y = event.y.toInt()
x -= widget.totalPaddingLeft
y -= widget.totalPaddingTop
x += widget.scrollX
y += widget.scrollY
val layout = widget.layout
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val link = buffer.getSpans(
off,
off,
LongClickableSpan::class.java
)
if (link.isNotEmpty()) {
if (action == MotionEvent.ACTION_UP) {
longClickHandler?.removeCallbacksAndMessages(null)
if (!isLongPressed) {
link[0].onClick(widget)
}
isLongPressed = false
} else {
Selection.setSelection(
buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0])
)
longClickHandler?.postDelayed({
link[0].onLongClick(widget)
isLongPressed = true
}, LONG_CLICK_TIME)
}
return true
}
}
return super.onTouchEvent(widget, buffer, event)
}
companion object {
private const val LONG_CLICK_TIME = 500L
val instance: MovementMethod?
get() {
if (sInstance == null) {
sInstance = LongClickLinkMovementMethod()
// Handler deprecated https://stackoverflow.com/a/62477706/4116924
sInstance!!.longClickHandler = Handler(Looper.getMainLooper())
}
return sInstance
}
private var sInstance: LongClickLinkMovementMethod? = null
}
}

View File

@ -1,21 +0,0 @@
package chat.revolt.internals.markdown
import chat.revolt.api.schemas.Emoji
import chat.revolt.api.schemas.User
import com.discord.simpleast.core.node.Node
import com.discord.simpleast.core.parser.Parser
typealias MarkdownParser = Parser<MarkdownContext, Node<MarkdownContext>, MarkdownState>
data class MarkdownState(val currentQuoteDepth: Int) {
fun newQuoteDepth(depth: Int): MarkdownState = MarkdownState(depth)
}
data class MarkdownContext(
val memberMap: Map<String, String>,
val userMap: Map<String, User>,
val channelMap: Map<String, String>,
val emojiMap: Map<String, Emoji>,
val serverId: String?,
val useLargeEmojis: Boolean
)

View File

@ -1,131 +0,0 @@
package chat.revolt.internals.markdown
import android.content.Context
import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import chat.revolt.api.REVOLT_FILES
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.load.resource.gif.GifDrawable
import com.bumptech.glide.request.target.CustomTarget
import com.discord.simpleast.core.node.Node
import kotlin.math.min
class UserMentionNode(private val userId: String) : Node<MarkdownContext>() {
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
val content = renderContext.memberMap[userId]?.let { "@$it" }
?: renderContext.userMap[userId]?.let { "@${it.username}" }
?: "<@$userId>"
builder.append(content)
builder.setSpan(
LinkSpan(
"revolt-android://link-action/user?user=$userId${
renderContext.serverId?.let {
"&server=$it"
}.orEmpty()
}",
drawBackground = true
),
builder.length - content.length,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
class ChannelMentionNode(private val channelId: String) : Node<MarkdownContext>() {
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
val content = renderContext.channelMap[channelId]?.let { "#$it" }
?: "<#$channelId>"
builder.append(content)
builder.setSpan(
LinkSpan(
"revolt-android://link-action/channel?channel=$channelId",
drawBackground = true
),
builder.length - content.length,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
class CustomEmoteNode(private val emoteId: String, private val context: Context) :
Node<MarkdownContext>() {
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
val content = renderContext.emojiMap[emoteId]?.let { ":${it.name}:" }
?: ":$emoteId:"
val isGif = renderContext.emojiMap[emoteId]?.animated ?: false
val emoteUrl = "$REVOLT_FILES/emojis/$emoteId/emote${if (isGif) ".gif" else ".png"}"
val density = context.resources.displayMetrics.density.toInt()
builder.append(content)
try {
Glide.with(context)
.asDrawable()
.load(emoteUrl)
.transition(DrawableTransitionOptions.withCrossFade())
.into(object : CustomTarget<Drawable>() {
override fun onLoadCleared(placeholder: Drawable?) {
// no-op
}
override fun onResourceReady(
resource: Drawable,
transition: com.bumptech.glide.request.transition.Transition<in Drawable>?
) {
if (resource is GifDrawable) {
resource.apply {
setLoopCount(GifDrawable.LOOP_FOREVER)
start()
}
}
val targetSize = if (renderContext.useLargeEmojis) 48 else 22
val maxWidth = if (renderContext.useLargeEmojis) 58 else 38
val wantWidth = min(
(resource.intrinsicWidth * (targetSize * density)) / resource.intrinsicHeight,
maxWidth * density
)
val wantHeight = targetSize * density
resource.setBounds(0, 0, wantWidth, wantHeight)
builder.setSpan(
EmoteSpan(resource),
builder.length - content.length,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
builder.setSpan(
EmoteClickableSpan(emoteId),
builder.length - content.length,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
})
} catch (e: Exception) {
Log.e("CustomEmoteNode", "Failed to load emote", e)
}
}
}
class LinkNode(val content: String, val url: String = content) : Node<MarkdownContext>() {
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
builder.append(content)
builder.setSpan(
LinkSpan(url),
builder.length - content.length,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}

View File

@ -1,206 +0,0 @@
package chat.revolt.internals.markdown
import android.content.Context
import android.text.style.BackgroundColorSpan
import android.text.style.TextAppearanceSpan
import chat.revolt.R
import com.discord.simpleast.code.CodeRules
import com.discord.simpleast.code.CodeStyleProviders
import com.discord.simpleast.core.node.Node
import com.discord.simpleast.core.node.StyleNode
import com.discord.simpleast.core.parser.ParseSpec
import com.discord.simpleast.core.parser.Parser
import com.discord.simpleast.core.parser.Rule
import java.util.regex.Matcher
import java.util.regex.Pattern
class UserMentionRule<S> :
Rule<MarkdownContext, UserMentionNode, S>(Pattern.compile("^<@([0-9A-Z]{26})>")) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in UserMentionNode, S>,
state: S
): ParseSpec<MarkdownContext, S> {
return ParseSpec.createTerminal(UserMentionNode(matcher.group(1)!!), state)
}
}
class ChannelMentionRule<S> :
Rule<MarkdownContext, ChannelMentionNode, S>(Pattern.compile("^<#([0-9A-Z]{26})>")) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in ChannelMentionNode, S>,
state: S
): ParseSpec<MarkdownContext, S> {
return ParseSpec.createTerminal(ChannelMentionNode(matcher.group(1)!!), state)
}
}
class CustomEmoteRule<S>(private val context: Context) :
Rule<MarkdownContext, CustomEmoteNode, S>(Pattern.compile("^:([0-9A-Z]{26}):")) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in CustomEmoteNode, S>,
state: S
): ParseSpec<MarkdownContext, S> {
return ParseSpec.createTerminal(CustomEmoteNode(matcher.group(1)!!, context), state)
}
}
class TimestampRule<S>(private val context: Context) :
Rule<MarkdownContext, Node<MarkdownContext>, S>(Pattern.compile("^<t:([0-9]+?)(:[tTDfFR])?>")) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in Node<MarkdownContext>, S>,
state: S
): ParseSpec<MarkdownContext, S> {
return ParseSpec.createTerminal(
StyleNode.wrapText(
resolveTimestamp(
try {
matcher.group(1)!!.toLong()
} catch (e: NumberFormatException) {
-1
},
matcher.group(2)
),
listOf(TextAppearanceSpan(context, R.style.Code_TextAppearance))
),
state
)
}
}
const val RE_LINK =
"<?https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)>?"
class LinkRule<S> : Rule<MarkdownContext, Node<MarkdownContext>, S>(
Pattern.compile("^$RE_LINK")
) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in Node<MarkdownContext>, S>,
state: S
): ParseSpec<MarkdownContext, S> {
val url = matcher.group(0)!!.trimStart('<').trimEnd('>')
return ParseSpec.createTerminal(
LinkNode(url),
state
)
}
}
class NamedLinkRule<S> : Rule<MarkdownContext, Node<MarkdownContext>, S>(
Pattern.compile("^\\[([^]]+)]\\(($RE_LINK)\\)")
) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in Node<MarkdownContext>, S>,
state: S
): ParseSpec<MarkdownContext, S> {
val content = matcher.group(1)!!
val url = matcher.group(2)!!.trimStart('<').trimEnd('>')
return ParseSpec.createTerminal(
LinkNode(content, url),
state
)
}
}
fun <RC, S> createInlineCodeRule(context: Context, backgroundColor: Int): Rule<RC, Node<RC>, S> {
return CodeRules.createInlineCodeRule(
{ listOf(TextAppearanceSpan(context, R.style.Code_TextAppearance)) },
{ listOf(BackgroundColorSpan(backgroundColor)) }
)
}
fun <RC> createCodeRule(context: Context, backgroundColor: Int): Rule<RC, Node<RC>, MarkdownState> {
val codeStyleProviders = CodeStyleProviders<RC>(
defaultStyleProvider = { listOf(TextAppearanceSpan(context, R.style.Code_TextAppearance)) },
commentStyleProvider = {
listOf(
TextAppearanceSpan(
context,
R.style.Code_TextAppearance_Comment
)
)
},
literalStyleProvider = {
listOf(
TextAppearanceSpan(
context,
R.style.Code_TextAppearance_Literal
)
)
},
keywordStyleProvider = {
listOf(
TextAppearanceSpan(
context,
R.style.Code_TextAppearance_Keyword
)
)
},
identifierStyleProvider = {
listOf(
TextAppearanceSpan(
context,
R.style.Code_TextAppearance_Identifier
)
)
},
typesStyleProvider = {
listOf(
TextAppearanceSpan(
context,
R.style.Code_TextAppearance_Types
)
)
},
genericsStyleProvider = {
listOf(
TextAppearanceSpan(
context,
R.style.Code_TextAppearance_Generics
)
)
},
paramsStyleProvider = {
listOf(
TextAppearanceSpan(
context,
R.style.Code_TextAppearance_Params
)
)
}
)
val languageMap = CodeRules.createCodeLanguageMap<RC, MarkdownState>(codeStyleProviders)
return CodeRules.createCodeRule(
codeStyleProviders.defaultStyleProvider,
languageMap
) { codeNode, block, state ->
if (!block) {
StyleNode<RC, Any>(listOf(BackgroundColorSpan(backgroundColor)))
.apply { addChild(codeNode) }
} else {
BlockBackgroundNode(
state.currentQuoteDepth,
backgroundColor,
backgroundColor,
codeNode
)
}
}
}
fun MarkdownParser.addRevoltRules(context: Context): MarkdownParser {
return addRules(
UserMentionRule(),
ChannelMentionRule(),
CustomEmoteRule(context),
TimestampRule(context),
NamedLinkRule(),
LinkRule()
)
}

View File

@ -2,12 +2,13 @@ package chat.revolt.ndk
annotation class NativeLibrary(val name: String) { annotation class NativeLibrary(val name: String) {
companion object { companion object {
const val LIB_NAME_ACCESS_CONTROL = "pipebomb" const val LIB_NAME_NATIVE_MARKDOWN = "stendal"
} }
} }
object NativeLibraries { object NativeLibraries {
fun init() { fun init() {
System.loadLibrary(NativeLibrary.LIB_NAME_ACCESS_CONTROL) System.loadLibrary(NativeLibrary.LIB_NAME_NATIVE_MARKDOWN)
Stendal.init()
} }
} }

View File

@ -1,12 +0,0 @@
package chat.revolt.ndk
@NativeLibrary(NativeLibrary.LIB_NAME_ACCESS_CONTROL)
object Pipebomb {
init {
System.loadLibrary(NativeLibrary.LIB_NAME_ACCESS_CONTROL)
}
external fun incrementHardCrashCounter()
external fun checkHardCrash(): Boolean
external fun doHardCrash()
}

View File

@ -0,0 +1,42 @@
package chat.revolt.ndk
import kotlinx.serialization.Serializable
object AstNodeListType {
const val NONE = 0
const val BULLET = 1
const val ORDERED = 2
}
object AstNodeDelimiterType {
const val NONE = 0
const val PERIOD = 1
const val PARENTHESIS = 2
}
@Serializable
data class AstNode(
val type: Int,
val stringType: String,
val children: List<AstNode>?,
val text: String?,
val url: String?,
val level: Int?,
val listType: Int?,
val delimiterType: Int?,
val listStart: Int?,
val listTight: Boolean?,
val fence: String?,
val title: String?,
val onEnter: String?,
val onExit: String?,
val startLine: Int?,
val endLine: Int?,
val startColumn: Int?,
val endColumn: Int?,
)
object Stendal {
external fun init()
external fun render(input: String): AstNode
}

View File

@ -2,11 +2,8 @@ package chat.revolt.screens.chat
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.accessibility.AccessibilityManager import android.view.accessibility.AccessibilityManager
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
@ -72,7 +69,6 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import chat.revolt.BuildConfig
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ChannelUtils import chat.revolt.api.internals.ChannelUtils
@ -82,9 +78,6 @@ import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.server.fetchMembers import chat.revolt.api.routes.server.fetchMembers
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.User import chat.revolt.api.schemas.User
import chat.revolt.api.settings.ClosedBetaAccessControlVariates
import chat.revolt.api.settings.FeatureFlag
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.api.settings.SyncedSettings import chat.revolt.api.settings.SyncedSettings
import chat.revolt.callbacks.Action import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel import chat.revolt.callbacks.ActionChannel
@ -97,7 +90,6 @@ import chat.revolt.components.screens.chat.drawer.server.DrawerServer
import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon
import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator
import chat.revolt.internals.Changelogs import chat.revolt.internals.Changelogs
import chat.revolt.ndk.Pipebomb
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
import chat.revolt.screens.chat.dialogs.safety.ReportServerDialog import chat.revolt.screens.chat.dialogs.safety.ReportServerDialog
@ -121,7 +113,6 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.sentry.Sentry
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -228,42 +219,6 @@ class ChatRouterViewModel @Inject constructor(
fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) { fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) {
if (!pure) setSaveCurrentChannel(channelId) if (!pure) setSaveCurrentChannel(channelId)
@FeatureFlag("ClosedBetaAccessControl")
if (RevoltAPI.channelCache.size > 0 &&
FeatureFlags.closedBetaAccessControl is ClosedBetaAccessControlVariates.Restricted &&
(FeatureFlags.closedBetaAccessControl as ClosedBetaAccessControlVariates.Restricted)
.predicate()
.not()
) {
Pipebomb.incrementHardCrashCounter()
navController.navigate("no_current_channel") {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route)
}
}
if (Pipebomb.checkHardCrash()) {
Toast.makeText(
context,
"You do not have access to the closed beta.",
Toast.LENGTH_SHORT
).show()
Sentry.init("") // we are about to crash on purpose, let's not send this to Sentry
val intent = Intent(Intent.ACTION_DELETE)
.setData(Uri.parse("package:${BuildConfig.APPLICATION_ID}"))
.setFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
)
context.startActivity(
intent
) // i'm just messing with the user at this point, they know what they did
Pipebomb.doHardCrash()
}
} else {
// Navigate as normal
navController.navigate("channel/$channelId") { navController.navigate("channel/$channelId") {
navController.graph.startDestinationRoute?.let { route -> navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) popUpTo(route)
@ -274,7 +229,6 @@ class ChatRouterViewModel @Inject constructor(
setSaveCurrentRoute("channel/$channelId") setSaveCurrentRoute("channel/$channelId")
} }
} }
}
fun navigateToSpecial(destination: String, navController: NavController) { fun navigateToSpecial(destination: String, navController: NavController) {
navController.navigate(destination) { navController.navigate(destination) {

View File

@ -12,18 +12,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -131,8 +128,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
message = message.copy( message = message.copy(
tail = false, tail = false,
masquerade = null masquerade = null
), )
truncate = false
) )
} }

View File

@ -55,7 +55,6 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -83,17 +82,9 @@ import chat.revolt.components.screens.chat.AttachmentManager
import chat.revolt.components.screens.chat.ChannelHeader import chat.revolt.components.screens.chat.ChannelHeader
import chat.revolt.components.screens.chat.ReplyManager import chat.revolt.components.screens.chat.ReplyManager
import chat.revolt.components.screens.chat.TypingIndicator import chat.revolt.components.screens.chat.TypingIndicator
import chat.revolt.internals.markdown.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule
import chat.revolt.sheets.ChannelInfoSheet import chat.revolt.sheets.ChannelInfoSheet
import chat.revolt.sheets.MessageContextSheet import chat.revolt.sheets.MessageContextSheet
import chat.revolt.sheets.ReactSheet import chat.revolt.sheets.ReactSheet
import com.discord.simpleast.core.simple.SimpleMarkdownRules
import com.discord.simpleast.core.simple.SimpleRenderer
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@ -115,8 +106,6 @@ fun ChannelScreen(
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val codeBlockColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var channelInfoSheetShown by remember { mutableStateOf(false) } var channelInfoSheetShown by remember { mutableStateOf(false) }
var messageContextSheetShown by remember { mutableStateOf(false) } var messageContextSheetShown by remember { mutableStateOf(false) }
@ -338,49 +327,6 @@ fun ChannelScreen(
message.system != null -> SystemMessage(message) message.system != null -> SystemMessage(message)
else -> Message( else -> Message(
message, message,
parse = {
val parser = MarkdownParser()
.addRules(
SimpleMarkdownRules.createEscapeRule()
)
.addRevoltRules(context)
.addRules(
createCodeRule(context, codeBlockColor.toArgb()),
createInlineCodeRule(
context,
codeBlockColor.toArgb()
)
)
.addRules(
SimpleMarkdownRules.createSimpleMarkdownRules(
includeEscapeRule = false
)
)
SimpleRenderer.render(
source = it.content ?: "",
parser = parser,
initialState = MarkdownState(0),
renderContext = MarkdownContext(
memberMap = viewModel.activeChannel?.server?.let { serverId ->
RevoltAPI.members.markdownMemberMapFor(
serverId
)
} ?: mapOf(),
userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id
?: "#DeletedChannel"
},
emojiMap = RevoltAPI.emojiCache,
serverId = channel?.server ?: "",
// check if message consists solely of one *or more* custom emotes
useLargeEmojis = it.content?.matches(
Regex("(:([0-9A-Z]{26}):)+")
) == true
)
)
},
onMessageContextMenu = { onMessageContextMenu = {
messageContextSheetShown = true messageContextSheetShown = true
messageContextSheetTarget = message.id ?: "" messageContextSheetTarget = message.id ?: ""

View File

@ -126,15 +126,6 @@ fun LabsHomeScreen(navController: NavController) {
} }
) )
Divider() Divider()
ListItem(
headlineContent = {
Text("XML Message Column")
},
modifier = Modifier.clickable {
navController.navigate("mockups/xmlmessage")
}
)
Divider()
} }
} }
} }

View File

@ -14,7 +14,6 @@ import androidx.navigation.compose.rememberNavController
import chat.revolt.api.settings.FeatureFlags import chat.revolt.api.settings.FeatureFlags
import chat.revolt.api.settings.LabsAccessControlVariates import chat.revolt.api.settings.LabsAccessControlVariates
import chat.revolt.screens.labs.ui.mockups.CallScreenMockup import chat.revolt.screens.labs.ui.mockups.CallScreenMockup
import chat.revolt.screens.labs.ui.mockups.XMLMessageMockup
annotation class LabsFeature annotation class LabsFeature
@ -66,10 +65,6 @@ fun LabsRootScreen(topNav: NavController) {
composable("mockups/call") { composable("mockups/call") {
CallScreenMockup() CallScreenMockup()
} }
composable("mockups/xmlmessage") {
XMLMessageMockup()
}
} }
} }
} }

View File

@ -1,122 +0,0 @@
package chat.revolt.screens.labs.ui.mockups
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import chat.revolt.api.RevoltAPI
import chat.revolt.api.schemas.Message
import chat.revolt.components.chat.Message
import chat.revolt.internals.markdown.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule
import chat.revolt.views.MessageView
import com.discord.simpleast.core.simple.SimpleMarkdownRules
import com.discord.simpleast.core.simple.SimpleRenderer
@Composable
fun XMLMessageMockup() {
var message by remember { mutableStateOf<Message?>(null) }
val context = LocalContext.current
val codeBlockColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
fun reroll() {
message = RevoltAPI.messageCache.values.random()
}
LaunchedEffect(Unit) {
reroll()
}
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
message?.let {
AndroidView(
factory = {
MessageView(it, onLongPress = {
Toast.makeText(context, "Long pressed!", Toast.LENGTH_SHORT).show()
})
},
update = {
it.fromMessage(message!!)
},
modifier = Modifier
.fillMaxWidth()
)
Message(
message = message!!.copy(tail = false),
truncate = false,
onMessageContextMenu = {
Toast.makeText(context, "Context menu!", Toast.LENGTH_SHORT).show()
},
parse = {
val parser = MarkdownParser()
.addRules(
SimpleMarkdownRules.createEscapeRule()
)
.addRevoltRules(context)
.addRules(
createCodeRule(context, codeBlockColor.toArgb()),
createInlineCodeRule(
context,
codeBlockColor.toArgb()
)
)
.addRules(
SimpleMarkdownRules.createSimpleMarkdownRules(
includeEscapeRule = false
)
)
SimpleRenderer.render(
source = it.content ?: "",
parser = parser,
initialState = MarkdownState(0),
renderContext = MarkdownContext(
memberMap = mapOf(),
userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id
?: "#DeletedChannel"
},
emojiMap = RevoltAPI.emojiCache,
serverId = message!!.channel?.let { x -> RevoltAPI.channelCache[x] }?.server
?: "",
// check if message consists solely of one *or more* custom emotes
useLargeEmojis = it.content?.matches(
Regex("(:([0-9A-Z]{26}):)+")
) == true
)
)
},
)
TextButton(onClick = { reroll() }) {
Text("Different message")
}
}
}
}

View File

@ -55,7 +55,7 @@ import chat.revolt.api.routes.auth.logoutAllSessions
import chat.revolt.api.routes.auth.logoutSessionById import chat.revolt.api.routes.auth.logoutSessionById
import chat.revolt.api.schemas.Session import chat.revolt.api.schemas.Session
import chat.revolt.components.generic.ListHeader import chat.revolt.components.generic.ListHeader
import chat.revolt.components.generic.UIMarkdown import chat.revolt.components.markdown.RichMarkdown
import chat.revolt.components.settings.sessions.SessionItem import chat.revolt.components.settings.sessions.SessionItem
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -201,12 +201,12 @@ fun SessionSettingsScreen(
} }
} ?: run { } ?: run {
item(key = "noCurrentSession") { item(key = "noCurrentSession") {
UIMarkdown( RichMarkdown(
text = stringResource(id = R.string.settings_sessions_this_device_unavailable), input = stringResource(id = R.string.settings_sessions_this_device_unavailable),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.padding(10.dp) .padding(16.dp)
) )
} }
} }

View File

@ -325,8 +325,7 @@ fun MessageContextSheet(
message = message.copy( message = message.copy(
tail = false, tail = false,
masquerade = null masquerade = null
), )
truncate = true
) )
} }

View File

@ -33,7 +33,7 @@ import chat.revolt.R
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.server.leaveOrDeleteServer import chat.revolt.api.routes.server.leaveOrDeleteServer
import chat.revolt.components.generic.SheetClickable import chat.revolt.components.generic.SheetClickable
import chat.revolt.components.generic.UIMarkdown import chat.revolt.components.markdown.RichMarkdown
import chat.revolt.components.screens.settings.ServerOverview import chat.revolt.components.screens.settings.ServerOverview
import chat.revolt.internals.Platform import chat.revolt.internals.Platform
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -151,8 +151,8 @@ fun ServerContextSheet(
modifier = Modifier.padding(vertical = 14.dp) modifier = Modifier.padding(vertical = 14.dp)
) )
UIMarkdown( RichMarkdown(
text = if (server.description?.isBlank() == false) { input = if (server.description?.isBlank() == false) {
server.description server.description
} else { } else {
stringResource( stringResource(

View File

@ -10,6 +10,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -29,8 +32,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.BrushCompat import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.solidColor import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.user.fetchUserProfile import chat.revolt.api.routes.user.fetchUserProfile
import chat.revolt.api.schemas.Profile import chat.revolt.api.schemas.Profile
@ -38,7 +41,7 @@ import chat.revolt.components.chat.RoleListEntry
import chat.revolt.components.chat.UserBadgeList import chat.revolt.components.chat.UserBadgeList
import chat.revolt.components.chat.UserBadgeRow import chat.revolt.components.chat.UserBadgeRow
import chat.revolt.components.generic.NonIdealState import chat.revolt.components.generic.NonIdealState
import chat.revolt.components.generic.WebMarkdown import chat.revolt.components.markdown.RichMarkdown
import chat.revolt.components.screens.settings.RawUserOverview import chat.revolt.components.screens.settings.RawUserOverview
import chat.revolt.components.screens.settings.UserButtons import chat.revolt.components.screens.settings.UserButtons
import chat.revolt.components.sheets.SheetTile import chat.revolt.components.sheets.SheetTile
@ -252,10 +255,9 @@ fun UserInfoSheet(
) )
} }
) { ) {
WebMarkdown( SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
text = profile?.content!!, RichMarkdown(input = profile?.content!!)
maskLoading = true }
)
} }
} }
} }

View File

@ -1,212 +0,0 @@
package chat.revolt.views
import android.content.Context
import android.icu.text.DateFormat
import android.text.format.DateUtils
import androidx.constraintlayout.widget.ConstraintLayout
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.TextViewCompat
import chat.revolt.api.internals.ULID
import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.User
import chat.revolt.databinding.ViewMessageBinding
import chat.revolt.internals.markdown.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule
import com.bumptech.glide.GenericTransitionOptions
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.transition.DrawableCrossFadeFactory
import com.discord.simpleast.core.simple.SimpleMarkdownRules
import com.discord.simpleast.core.simple.SimpleRenderer
import com.google.android.material.color.MaterialColors
import com.google.android.material.elevation.SurfaceColors
class MessageView(
ctx: Context
) : ConstraintLayout(ctx) {
private var binding: ViewMessageBinding
private val parser = MarkdownParser()
.addRules(
SimpleMarkdownRules.createEscapeRule()
)
.addRevoltRules(context)
.addRules(
createCodeRule(context, SurfaceColors.SURFACE_2.getColor(ctx)),
createInlineCodeRule(
context,
SurfaceColors.SURFACE_2.getColor(ctx),
)
)
.addRules(
SimpleMarkdownRules.createSimpleMarkdownRules(
includeEscapeRule = false
)
)
private var messageServer: String? = null
constructor(
ctx: Context,
onLongPress: (() -> Unit)? = null
) : this(ctx) {
binding.root.setOnLongClickListener {
onLongPress?.invoke()
onLongPress != null
}
}
init {
inflate(ctx, R.layout.view_message, this)
binding = ViewMessageBinding.bind(this)
}
fun setAuthor(author: String) {
binding.author.text = author
}
fun setContent(content: String) {
binding.messageContent.text = SimpleRenderer.render(
source = content,
parser = parser,
initialState = MarkdownState(0),
renderContext = MarkdownContext(
memberMap = messageServer?.let { RevoltAPI.members.markdownMemberMapFor(it) }
?: mapOf(),
userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id
?: "#DeletedChannel"
},
emojiMap = RevoltAPI.emojiCache,
serverId = messageServer,
// check if message consists solely of one *or more* custom emotes
useLargeEmojis = content.matches(
Regex("(:([0-9A-Z]{26}):)+")
)
)
)
}
fun setTimestamp(timestamp: String) {
binding.timestamp.text = timestamp
}
fun setAvatarUrl(avatar: String?) {
if (avatar == null) {
Glide.with(this).clear(binding.avatar)
}
val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build()
Glide.with(this).load(avatar).diskCacheStrategy(DiskCacheStrategy.ALL)
.transition(GenericTransitionOptions.with(factory))
.circleCrop()
.into(binding.avatar)
}
private fun formatLongAsTime(time: Long): String {
val date = java.util.Date(time)
val withinLastWeek = System.currentTimeMillis() - time < 604800000
return if (withinLastWeek) {
val relativeDate = DateUtils.getRelativeTimeSpanString(
time,
System.currentTimeMillis(),
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_ABBREV_ALL
)
val relativeTime = DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
"$relativeDate $relativeTime"
} else {
val absoluteDate = DateFormat.getDateInstance(DateFormat.SHORT).format(date)
val absoluteTime = DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
"$absoluteDate $absoluteTime"
}
}
private fun authorName(message: Message): String {
if (message.masquerade?.name != null) {
return message.masquerade.name
}
messageServer
?: return RevoltAPI.userCache[message.author]?.let { User.resolveDefaultName(it) }
?: context.getString(R.string.unknown)
val member = messageServer?.let { sid ->
message.author?.let {
RevoltAPI.members.getMember(
sid,
it
)
}
}
?: return context.getString(R.string.unknown)
return member.nickname
?: RevoltAPI.userCache[message.author]?.let { User.resolveDefaultName(it) }
?: context.getString(R.string.unknown)
}
private fun resetAuthorColour() {
binding.author.setTextColor(
MaterialColors.getColor(
binding.author,
com.google.android.material.R.attr.colorOnBackground
)
)
binding.author.paint.shader = null
}
private fun setAuthorColour(message: Message) {
resetAuthorColour()
if (message.masquerade?.colour != null) {
TextViewCompat.setColourFromRoleColour(binding.author, message.masquerade.colour)
} else {
val serverId = RevoltAPI.channelCache[message.channel]?.server ?: return
val highestRole = message.author?.let {
Roles.resolveHighestRole(serverId, it, withColour = true)
} ?: return
highestRole.colour?.let {
TextViewCompat.setColourFromRoleColour(binding.author, it)
}
}
}
fun fromMessage(message: Message) {
messageServer = RevoltAPI.channelCache[message.channel]?.server
message.content?.let { setContent(it) }
message.id?.let { setTimestamp(formatLongAsTime(ULID.asTimestamp(it))) }
// dont have this
val resolvedAuthor = RevoltAPI.userCache[message.author]
// dont inline this
setAvatarUrl(resolvedAuthor?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}?max_side=256" }
?: "")
setAuthor(authorName(message))
setAuthorColour(message)
}
fun reset() {
resetAuthorColour()
setContent("")
setTimestamp("")
setAuthor("")
setAvatarUrl(null)
}
}