feat(stendal): initial implementation/switch
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
ff8eca0863
commit
17d44622cc
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "app/src/main/cpp/external/cmark"]
|
||||
path = app/src/main/cpp/external/cmark
|
||||
url = https://github.com/commonmark/cmark
|
||||
|
|
@ -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>
|
||||
|
|
@ -2,5 +2,6 @@
|
|||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/external/cmark" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -245,9 +245,6 @@ dependencies {
|
|||
// JDK Desugaring - polyfill for new Java APIs
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||
|
||||
// Markdown
|
||||
implementation "com.github.discord:SimpleAST:2.7.0"
|
||||
|
||||
// AndroidX Media3 w/ ExoPlayer
|
||||
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ cmake_minimum_required(VERSION 3.22.1)
|
|||
# build script scope).
|
||||
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
|
||||
# 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;
|
||||
# for GameActivity/NativeActivity derived applications, the same library name must be
|
||||
# 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.
|
||||
lib${LIB_NAME_ACCESS_CONTROL}/${LIB_NAME_ACCESS_CONTROL}.cpp
|
||||
stendal/stendal.cpp
|
||||
)
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link libraries from various origins, such as libraries defined in this
|
||||
# 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
|
||||
cmark
|
||||
android
|
||||
log)
|
||||
log)
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 3337a30715a641274a5a14aa167d6e51ba4066c0
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
package chat.revolt.api.routes.microservices.january
|
||||
|
||||
import chat.revolt.api.REVOLT_JANUARY
|
||||
import java.net.URLEncoder
|
||||
|
||||
fun asJanuaryProxyUrl(url: String): String {
|
||||
return "$REVOLT_JANUARY/proxy?url=$url"
|
||||
return "$REVOLT_JANUARY/proxy?url=${URLEncoder.encode(url, "utf-8")}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,6 @@ import chat.revolt.api.internals.SpecialUsers
|
|||
annotation class FeatureFlag(val name: 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")
|
||||
sealed class LabsAccessControlVariates {
|
||||
@Treatment(
|
||||
|
|
@ -29,13 +18,6 @@ sealed class LabsAccessControlVariates {
|
|||
}
|
||||
|
||||
object FeatureFlags {
|
||||
@FeatureFlag("ClosedBetaAccessControl")
|
||||
var closedBetaAccessControl by mutableStateOf<ClosedBetaAccessControlVariates>(
|
||||
ClosedBetaAccessControlVariates.Restricted {
|
||||
RevoltAPI.channelCache.containsKey("01H7X2KRB0CA4QDSMB4N7WGERF")
|
||||
}
|
||||
)
|
||||
|
||||
@FeatureFlag("LabsAccessControl")
|
||||
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
|
||||
LabsAccessControlVariates.Restricted {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ import chat.revolt.api.internals.BrushCompat
|
|||
import chat.revolt.api.internals.solidColor
|
||||
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
|
||||
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.UIMarkdown
|
||||
import chat.revolt.components.markdown.RichMarkdown
|
||||
import chat.revolt.api.schemas.Embed as EmbedSchema
|
||||
|
||||
@Composable
|
||||
fun RegularEmbed(
|
||||
|
|
@ -100,8 +100,8 @@ fun RegularEmbed(
|
|||
|
||||
// Description
|
||||
embed.description?.let {
|
||||
UIMarkdown(
|
||||
text = it,
|
||||
RichMarkdown(
|
||||
input = it,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,7 @@ package chat.revolt.components.chat
|
|||
import android.content.Intent
|
||||
import android.icu.text.DateFormat
|
||||
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.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.View.OnTouchListener
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
|
@ -43,13 +34,13 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.unit.dp
|
||||
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.activities.media.ImageViewActivity
|
||||
import chat.revolt.activities.media.VideoViewActivity
|
||||
|
|
@ -79,7 +67,8 @@ import chat.revolt.callbacks.Action
|
|||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.components.generic.UserAvatar
|
||||
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 chat.revolt.api.schemas.Message as MessageSchema
|
||||
|
||||
|
|
@ -172,8 +161,6 @@ fun formatLongAsTime(time: Long): String {
|
|||
@Composable
|
||||
fun Message(
|
||||
message: MessageSchema,
|
||||
truncate: Boolean = false,
|
||||
parse: (MessageSchema) -> SpannableStringBuilder = { SpannableStringBuilder(it.content) },
|
||||
onMessageContextMenu: () -> Unit = {},
|
||||
onAvatarClick: () -> Unit = {},
|
||||
onNameClick: (() -> Unit)? = null,
|
||||
|
|
@ -183,7 +170,6 @@ fun Message(
|
|||
) {
|
||||
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
|
||||
val context = LocalContext.current
|
||||
val contentColor = LocalContentColor.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -326,94 +312,13 @@ fun Message(
|
|||
message.content?.let {
|
||||
if (message.content.isBlank()) return@let // if only an attachment is sent
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
androidx.appcompat.widget.AppCompatTextView(ctx).apply {
|
||||
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()
|
||||
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)
|
||||
}
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
|
||||
currentServer = RevoltAPI.channelCache[message.channel]?.server
|
||||
)
|
||||
) {
|
||||
RichMarkdown(input = message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.schemas.Message
|
||||
import chat.revolt.components.generic.UIMarkdown
|
||||
import chat.revolt.components.markdown.RichMarkdown
|
||||
|
||||
enum class SystemMessageType(val type: String) {
|
||||
CHANNEL_OWNERSHIP_CHANGED("channel_ownership_changed"),
|
||||
|
|
@ -78,7 +78,7 @@ fun SystemMessage(message: Message) {
|
|||
|
||||
when (systemMessageType) {
|
||||
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_ownership_changed,
|
||||
message.system.from.mention(),
|
||||
|
|
@ -88,7 +88,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.CHANNEL_ICON_CHANGED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_channel_icon_changed,
|
||||
message.system.by.mention()
|
||||
|
|
@ -97,7 +97,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_channel_description_changed,
|
||||
message.system.by.mention()
|
||||
|
|
@ -106,7 +106,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.CHANNEL_RENAMED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_channel_renamed,
|
||||
message.system.by.mention(),
|
||||
|
|
@ -116,7 +116,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.USER_REMOVE -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_user_removed,
|
||||
message.system.by.mention(),
|
||||
|
|
@ -126,7 +126,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.USER_ADDED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_user_added,
|
||||
message.system.by.mention(),
|
||||
|
|
@ -136,7 +136,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.USER_BANNED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_user_banned,
|
||||
message.system.id.mention()
|
||||
|
|
@ -145,7 +145,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.USER_KICKED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_user_kicked,
|
||||
message.system.id.mention()
|
||||
|
|
@ -154,7 +154,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.USER_LEFT -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_user_left,
|
||||
message.system.id.mention()
|
||||
|
|
@ -163,7 +163,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.USER_JOINED -> {
|
||||
UIMarkdown(
|
||||
RichMarkdown(
|
||||
stringResource(
|
||||
R.string.system_message_user_joined,
|
||||
message.system.id.mention()
|
||||
|
|
@ -172,7 +172,7 @@ fun SystemMessage(message: Message) {
|
|||
}
|
||||
|
||||
SystemMessageType.TEXT -> {
|
||||
message.system.content?.let { UIMarkdown(it) }
|
||||
message.system.content?.let { RichMarkdown(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package chat.revolt.internals.extensions
|
||||
package chat.revolt.components.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
package chat.revolt.internals.markdown
|
||||
package chat.revolt.internals
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
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 {
|
||||
val normalisedModifier = modifier.orEmpty().removePrefix(":")
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@ package chat.revolt.ndk
|
|||
|
||||
annotation class NativeLibrary(val name: String) {
|
||||
companion object {
|
||||
const val LIB_NAME_ACCESS_CONTROL = "pipebomb"
|
||||
const val LIB_NAME_NATIVE_MARKDOWN = "stendal"
|
||||
}
|
||||
}
|
||||
|
||||
object NativeLibraries {
|
||||
fun init() {
|
||||
System.loadLibrary(NativeLibrary.LIB_NAME_ACCESS_CONTROL)
|
||||
System.loadLibrary(NativeLibrary.LIB_NAME_NATIVE_MARKDOWN)
|
||||
Stendal.init()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -2,11 +2,8 @@ package chat.revolt.screens.chat
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
|
|
@ -72,7 +69,6 @@ import androidx.navigation.compose.NavHost
|
|||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import chat.revolt.BuildConfig
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.RevoltAPI
|
||||
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.schemas.ChannelType
|
||||
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.callbacks.Action
|
||||
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.ServerDrawerSeparator
|
||||
import chat.revolt.internals.Changelogs
|
||||
import chat.revolt.ndk.Pipebomb
|
||||
import chat.revolt.persistence.KVStorage
|
||||
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.sentry.Sentry
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
|
@ -228,51 +219,14 @@ class ChatRouterViewModel @Inject constructor(
|
|||
fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) {
|
||||
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)
|
||||
}
|
||||
navController.navigate("channel/$channelId") {
|
||||
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.graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
setSaveCurrentRoute("channel/$channelId")
|
||||
}
|
||||
viewModelScope.launch {
|
||||
setSaveCurrentRoute("channel/$channelId")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,18 +12,15 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.Close
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -131,8 +128,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
|
|||
message = message.copy(
|
||||
tail = false,
|
||||
masquerade = null
|
||||
),
|
||||
truncate = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ import androidx.compose.runtime.snapshotFlow
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
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.ReplyManager
|
||||
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.MessageContextSheet
|
||||
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.launch
|
||||
import java.io.File
|
||||
|
|
@ -115,8 +106,6 @@ fun ChannelScreen(
|
|||
val lazyListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val codeBlockColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
|
||||
|
||||
var channelInfoSheetShown by remember { mutableStateOf(false) }
|
||||
|
||||
var messageContextSheetShown by remember { mutableStateOf(false) }
|
||||
|
|
@ -338,49 +327,6 @@ fun ChannelScreen(
|
|||
message.system != null -> SystemMessage(message)
|
||||
else -> 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 = {
|
||||
messageContextSheetShown = true
|
||||
messageContextSheetTarget = message.id ?: ""
|
||||
|
|
|
|||
|
|
@ -126,15 +126,6 @@ fun LabsHomeScreen(navController: NavController) {
|
|||
}
|
||||
)
|
||||
Divider()
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text("XML Message Column")
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
navController.navigate("mockups/xmlmessage")
|
||||
}
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.navigation.compose.rememberNavController
|
|||
import chat.revolt.api.settings.FeatureFlags
|
||||
import chat.revolt.api.settings.LabsAccessControlVariates
|
||||
import chat.revolt.screens.labs.ui.mockups.CallScreenMockup
|
||||
import chat.revolt.screens.labs.ui.mockups.XMLMessageMockup
|
||||
|
||||
annotation class LabsFeature
|
||||
|
||||
|
|
@ -66,10 +65,6 @@ fun LabsRootScreen(topNav: NavController) {
|
|||
composable("mockups/call") {
|
||||
CallScreenMockup()
|
||||
}
|
||||
|
||||
composable("mockups/xmlmessage") {
|
||||
XMLMessageMockup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ import chat.revolt.api.routes.auth.logoutAllSessions
|
|||
import chat.revolt.api.routes.auth.logoutSessionById
|
||||
import chat.revolt.api.schemas.Session
|
||||
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 kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -201,12 +201,12 @@ fun SessionSettingsScreen(
|
|||
}
|
||||
} ?: run {
|
||||
item(key = "noCurrentSession") {
|
||||
UIMarkdown(
|
||||
text = stringResource(id = R.string.settings_sessions_this_device_unavailable),
|
||||
RichMarkdown(
|
||||
input = stringResource(id = R.string.settings_sessions_this_device_unavailable),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(10.dp)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,8 +325,7 @@ fun MessageContextSheet(
|
|||
message = message.copy(
|
||||
tail = false,
|
||||
masquerade = null
|
||||
),
|
||||
truncate = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import chat.revolt.R
|
|||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.routes.server.leaveOrDeleteServer
|
||||
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.internals.Platform
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -151,8 +151,8 @@ fun ServerContextSheet(
|
|||
modifier = Modifier.padding(vertical = 14.dp)
|
||||
)
|
||||
|
||||
UIMarkdown(
|
||||
text = if (server.description?.isBlank() == false) {
|
||||
RichMarkdown(
|
||||
input = if (server.description?.isBlank() == false) {
|
||||
server.description
|
||||
} else {
|
||||
stringResource(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
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.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -29,8 +32,8 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.internals.ULID
|
||||
import chat.revolt.api.internals.BrushCompat
|
||||
import chat.revolt.api.internals.ULID
|
||||
import chat.revolt.api.internals.solidColor
|
||||
import chat.revolt.api.routes.user.fetchUserProfile
|
||||
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.UserBadgeRow
|
||||
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.UserButtons
|
||||
import chat.revolt.components.sheets.SheetTile
|
||||
|
|
@ -252,10 +255,9 @@ fun UserInfoSheet(
|
|||
)
|
||||
}
|
||||
) {
|
||||
WebMarkdown(
|
||||
text = profile?.content!!,
|
||||
maskLoading = true
|
||||
)
|
||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
RichMarkdown(input = profile?.content!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue