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">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/external/cmark" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -245,9 +245,6 @@ dependencies {
|
||||||
// JDK Desugaring - polyfill for new Java APIs
|
// JDK Desugaring - polyfill for new Java APIs
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||||
|
|
||||||
// Markdown
|
|
||||||
implementation "com.github.discord:SimpleAST:2.7.0"
|
|
||||||
|
|
||||||
// AndroidX Media3 w/ ExoPlayer
|
// AndroidX Media3 w/ ExoPlayer
|
||||||
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
||||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ cmake_minimum_required(VERSION 3.22.1)
|
||||||
# build script scope).
|
# build script scope).
|
||||||
project("revolt")
|
project("revolt")
|
||||||
|
|
||||||
set(LIB_NAME_ACCESS_CONTROL "pipebomb")
|
# Compile cmark in external/cmark
|
||||||
|
add_subdirectory(external/cmark)
|
||||||
|
|
||||||
# Creates and names a library, sets it as either STATIC
|
# Creates and names a library, sets it as either STATIC
|
||||||
# or SHARED, and provides the relative paths to its source code.
|
# or SHARED, and provides the relative paths to its source code.
|
||||||
|
|
@ -27,15 +28,16 @@ set(LIB_NAME_ACCESS_CONTROL "pipebomb")
|
||||||
# System.loadLibrary() and pass the name of the library defined here;
|
# System.loadLibrary() and pass the name of the library defined here;
|
||||||
# for GameActivity/NativeActivity derived applications, the same library name must be
|
# for GameActivity/NativeActivity derived applications, the same library name must be
|
||||||
# used in the AndroidManifest.xml file.
|
# used in the AndroidManifest.xml file.
|
||||||
add_library(${LIB_NAME_ACCESS_CONTROL} SHARED
|
add_library(stendal SHARED
|
||||||
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
||||||
lib${LIB_NAME_ACCESS_CONTROL}/${LIB_NAME_ACCESS_CONTROL}.cpp
|
stendal/stendal.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
# Specifies libraries CMake should link to your target library. You
|
# Specifies libraries CMake should link to your target library. You
|
||||||
# can link libraries from various origins, such as libraries defined in this
|
# can link libraries from various origins, such as libraries defined in this
|
||||||
# build script, prebuilt third-party libraries, or Android system libraries.
|
# build script, prebuilt third-party libraries, or Android system libraries.
|
||||||
target_link_libraries(${LIB_NAME_ACCESS_CONTROL}
|
target_link_libraries(stendal
|
||||||
# List libraries link to the target library
|
# List libraries link to the target library
|
||||||
|
cmark
|
||||||
android
|
android
|
||||||
log)
|
log)
|
||||||
|
|
@ -0,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
|
package chat.revolt.api.routes.microservices.january
|
||||||
|
|
||||||
import chat.revolt.api.REVOLT_JANUARY
|
import chat.revolt.api.REVOLT_JANUARY
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
fun asJanuaryProxyUrl(url: String): String {
|
fun asJanuaryProxyUrl(url: String): String {
|
||||||
return "$REVOLT_JANUARY/proxy?url=$url"
|
return "$REVOLT_JANUARY/proxy?url=${URLEncoder.encode(url, "utf-8")}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,6 @@ import chat.revolt.api.internals.SpecialUsers
|
||||||
annotation class FeatureFlag(val name: String)
|
annotation class FeatureFlag(val name: String)
|
||||||
annotation class Treatment(val description: String)
|
annotation class Treatment(val description: String)
|
||||||
|
|
||||||
@FeatureFlag("ClosedBetaAccessControl")
|
|
||||||
sealed class ClosedBetaAccessControlVariates {
|
|
||||||
@Treatment(
|
|
||||||
"Restrict access to the app to users that meet certain or all criteria (implementation-specific)"
|
|
||||||
)
|
|
||||||
data class Restricted(val predicate: () -> Boolean) : ClosedBetaAccessControlVariates()
|
|
||||||
|
|
||||||
@Treatment("Allow access to the app to all users")
|
|
||||||
data object Unrestricted : ClosedBetaAccessControlVariates()
|
|
||||||
}
|
|
||||||
|
|
||||||
@FeatureFlag("LabsAccessControl")
|
@FeatureFlag("LabsAccessControl")
|
||||||
sealed class LabsAccessControlVariates {
|
sealed class LabsAccessControlVariates {
|
||||||
@Treatment(
|
@Treatment(
|
||||||
|
|
@ -29,13 +18,6 @@ sealed class LabsAccessControlVariates {
|
||||||
}
|
}
|
||||||
|
|
||||||
object FeatureFlags {
|
object FeatureFlags {
|
||||||
@FeatureFlag("ClosedBetaAccessControl")
|
|
||||||
var closedBetaAccessControl by mutableStateOf<ClosedBetaAccessControlVariates>(
|
|
||||||
ClosedBetaAccessControlVariates.Restricted {
|
|
||||||
RevoltAPI.channelCache.containsKey("01H7X2KRB0CA4QDSMB4N7WGERF")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@FeatureFlag("LabsAccessControl")
|
@FeatureFlag("LabsAccessControl")
|
||||||
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
|
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
|
||||||
LabsAccessControlVariates.Restricted {
|
LabsAccessControlVariates.Restricted {
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ import chat.revolt.api.internals.BrushCompat
|
||||||
import chat.revolt.api.internals.solidColor
|
import chat.revolt.api.internals.solidColor
|
||||||
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
|
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
|
||||||
import chat.revolt.api.schemas.Embed
|
import chat.revolt.api.schemas.Embed
|
||||||
import chat.revolt.api.schemas.Embed as EmbedSchema
|
|
||||||
import chat.revolt.components.generic.RemoteImage
|
import chat.revolt.components.generic.RemoteImage
|
||||||
import chat.revolt.components.generic.UIMarkdown
|
import chat.revolt.components.markdown.RichMarkdown
|
||||||
|
import chat.revolt.api.schemas.Embed as EmbedSchema
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RegularEmbed(
|
fun RegularEmbed(
|
||||||
|
|
@ -100,8 +100,8 @@ fun RegularEmbed(
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
embed.description?.let {
|
embed.description?.let {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
text = it,
|
input = it,
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,7 @@ package chat.revolt.components.chat
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.icu.text.DateFormat
|
import android.icu.text.DateFormat
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.text.Selection
|
|
||||||
import android.text.SpannableStringBuilder
|
|
||||||
import android.text.SpannedString
|
|
||||||
import android.text.TextUtils
|
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.view.MotionEvent
|
|
||||||
import android.view.View
|
|
||||||
import android.view.View.OnTouchListener
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
|
@ -43,13 +34,13 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.surfaceColorAtElevation
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -57,9 +48,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.core.content.res.ResourcesCompat
|
|
||||||
import androidx.core.text.toSpannable
|
|
||||||
import chat.revolt.R
|
import chat.revolt.R
|
||||||
import chat.revolt.activities.media.ImageViewActivity
|
import chat.revolt.activities.media.ImageViewActivity
|
||||||
import chat.revolt.activities.media.VideoViewActivity
|
import chat.revolt.activities.media.VideoViewActivity
|
||||||
|
|
@ -79,7 +67,8 @@ import chat.revolt.callbacks.Action
|
||||||
import chat.revolt.callbacks.ActionChannel
|
import chat.revolt.callbacks.ActionChannel
|
||||||
import chat.revolt.components.generic.UserAvatar
|
import chat.revolt.components.generic.UserAvatar
|
||||||
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
|
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
|
||||||
import chat.revolt.internals.markdown.LongClickableSpan
|
import chat.revolt.components.markdown.LocalMarkdownTreeConfig
|
||||||
|
import chat.revolt.components.markdown.RichMarkdown
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import chat.revolt.api.schemas.Message as MessageSchema
|
import chat.revolt.api.schemas.Message as MessageSchema
|
||||||
|
|
||||||
|
|
@ -172,8 +161,6 @@ fun formatLongAsTime(time: Long): String {
|
||||||
@Composable
|
@Composable
|
||||||
fun Message(
|
fun Message(
|
||||||
message: MessageSchema,
|
message: MessageSchema,
|
||||||
truncate: Boolean = false,
|
|
||||||
parse: (MessageSchema) -> SpannableStringBuilder = { SpannableStringBuilder(it.content) },
|
|
||||||
onMessageContextMenu: () -> Unit = {},
|
onMessageContextMenu: () -> Unit = {},
|
||||||
onAvatarClick: () -> Unit = {},
|
onAvatarClick: () -> Unit = {},
|
||||||
onNameClick: (() -> Unit)? = null,
|
onNameClick: (() -> Unit)? = null,
|
||||||
|
|
@ -183,7 +170,6 @@ fun Message(
|
||||||
) {
|
) {
|
||||||
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
|
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val contentColor = LocalContentColor.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -326,94 +312,13 @@ fun Message(
|
||||||
message.content?.let {
|
message.content?.let {
|
||||||
if (message.content.isBlank()) return@let // if only an attachment is sent
|
if (message.content.isBlank()) return@let // if only an attachment is sent
|
||||||
|
|
||||||
AndroidView(
|
CompositionLocalProvider(
|
||||||
factory = { ctx ->
|
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
|
||||||
androidx.appcompat.widget.AppCompatTextView(ctx).apply {
|
currentServer = RevoltAPI.channelCache[message.channel]?.server
|
||||||
maxLines = if (truncate) 1 else Int.MAX_VALUE
|
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
|
||||||
textSize = 16f
|
|
||||||
typeface = ResourcesCompat.getFont(ctx, R.font.inter)
|
|
||||||
|
|
||||||
setOnTouchListener(object : OnTouchListener {
|
|
||||||
private var longClickHandler: Handler? = null
|
|
||||||
private var isLongPressed = false
|
|
||||||
|
|
||||||
override fun onTouch(
|
|
||||||
widget: View?,
|
|
||||||
event: MotionEvent?
|
|
||||||
): Boolean {
|
|
||||||
if (longClickHandler == null) {
|
|
||||||
longClickHandler =
|
|
||||||
Handler(Looper.getMainLooper())
|
|
||||||
}
|
|
||||||
if (event == null) return false
|
|
||||||
|
|
||||||
val action = event.action
|
|
||||||
if (action == MotionEvent.ACTION_CANCEL) {
|
|
||||||
longClickHandler?.removeCallbacksAndMessages(
|
|
||||||
null
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if (action == MotionEvent.ACTION_UP ||
|
|
||||||
action == MotionEvent.ACTION_DOWN
|
|
||||||
) {
|
) {
|
||||||
var x = event.x.toInt()
|
RichMarkdown(input = message.content)
|
||||||
var y = event.y.toInt()
|
|
||||||
x -= (widget as androidx.appcompat.widget.AppCompatTextView).totalPaddingLeft
|
|
||||||
y -= widget.totalPaddingTop
|
|
||||||
x += widget.scrollX
|
|
||||||
y += widget.scrollY
|
|
||||||
|
|
||||||
val spannedString = widget.text as SpannedString
|
|
||||||
|
|
||||||
val layout = widget.layout
|
|
||||||
val line = layout.getLineForVertical(y)
|
|
||||||
val off = layout.getOffsetForHorizontal(
|
|
||||||
line,
|
|
||||||
x.toFloat()
|
|
||||||
)
|
|
||||||
val link = spannedString.getSpans(
|
|
||||||
off,
|
|
||||||
off,
|
|
||||||
LongClickableSpan::class.java
|
|
||||||
)
|
|
||||||
if (link.isNotEmpty()) {
|
|
||||||
if (action == MotionEvent.ACTION_UP) {
|
|
||||||
longClickHandler?.removeCallbacksAndMessages(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
if (!isLongPressed) {
|
|
||||||
link[0].onClick(widget)
|
|
||||||
}
|
}
|
||||||
isLongPressed = false
|
|
||||||
} else {
|
|
||||||
Selection.setSelection(
|
|
||||||
spannedString.toSpannable(),
|
|
||||||
spannedString.getSpanStart(link[0]),
|
|
||||||
spannedString.getSpanEnd(link[0])
|
|
||||||
)
|
|
||||||
longClickHandler?.postDelayed(
|
|
||||||
{
|
|
||||||
link[0].onLongClick(widget)
|
|
||||||
isLongPressed = true
|
|
||||||
},
|
|
||||||
250L
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTextColor(contentColor.toArgb())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update = {
|
|
||||||
it.text = parse(message)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.revolt.R
|
import chat.revolt.R
|
||||||
import chat.revolt.api.schemas.Message
|
import chat.revolt.api.schemas.Message
|
||||||
import chat.revolt.components.generic.UIMarkdown
|
import chat.revolt.components.markdown.RichMarkdown
|
||||||
|
|
||||||
enum class SystemMessageType(val type: String) {
|
enum class SystemMessageType(val type: String) {
|
||||||
CHANNEL_OWNERSHIP_CHANGED("channel_ownership_changed"),
|
CHANNEL_OWNERSHIP_CHANGED("channel_ownership_changed"),
|
||||||
|
|
@ -78,7 +78,7 @@ fun SystemMessage(message: Message) {
|
||||||
|
|
||||||
when (systemMessageType) {
|
when (systemMessageType) {
|
||||||
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
|
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_ownership_changed,
|
R.string.system_message_ownership_changed,
|
||||||
message.system.from.mention(),
|
message.system.from.mention(),
|
||||||
|
|
@ -88,7 +88,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.CHANNEL_ICON_CHANGED -> {
|
SystemMessageType.CHANNEL_ICON_CHANGED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_channel_icon_changed,
|
R.string.system_message_channel_icon_changed,
|
||||||
message.system.by.mention()
|
message.system.by.mention()
|
||||||
|
|
@ -97,7 +97,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
|
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_channel_description_changed,
|
R.string.system_message_channel_description_changed,
|
||||||
message.system.by.mention()
|
message.system.by.mention()
|
||||||
|
|
@ -106,7 +106,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.CHANNEL_RENAMED -> {
|
SystemMessageType.CHANNEL_RENAMED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_channel_renamed,
|
R.string.system_message_channel_renamed,
|
||||||
message.system.by.mention(),
|
message.system.by.mention(),
|
||||||
|
|
@ -116,7 +116,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_REMOVE -> {
|
SystemMessageType.USER_REMOVE -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_removed,
|
R.string.system_message_user_removed,
|
||||||
message.system.by.mention(),
|
message.system.by.mention(),
|
||||||
|
|
@ -126,7 +126,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_ADDED -> {
|
SystemMessageType.USER_ADDED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_added,
|
R.string.system_message_user_added,
|
||||||
message.system.by.mention(),
|
message.system.by.mention(),
|
||||||
|
|
@ -136,7 +136,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_BANNED -> {
|
SystemMessageType.USER_BANNED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_banned,
|
R.string.system_message_user_banned,
|
||||||
message.system.id.mention()
|
message.system.id.mention()
|
||||||
|
|
@ -145,7 +145,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_KICKED -> {
|
SystemMessageType.USER_KICKED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_kicked,
|
R.string.system_message_user_kicked,
|
||||||
message.system.id.mention()
|
message.system.id.mention()
|
||||||
|
|
@ -154,7 +154,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_LEFT -> {
|
SystemMessageType.USER_LEFT -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_left,
|
R.string.system_message_user_left,
|
||||||
message.system.id.mention()
|
message.system.id.mention()
|
||||||
|
|
@ -163,7 +163,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_JOINED -> {
|
SystemMessageType.USER_JOINED -> {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_joined,
|
R.string.system_message_user_joined,
|
||||||
message.system.id.mention()
|
message.system.id.mention()
|
||||||
|
|
@ -172,7 +172,7 @@ fun SystemMessage(message: Message) {
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.TEXT -> {
|
SystemMessageType.TEXT -> {
|
||||||
message.system.content?.let { UIMarkdown(it) }
|
message.system.content?.let { RichMarkdown(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
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.text.format.DateUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.datetime.toJavaInstant
|
import kotlinx.datetime.toJavaInstant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
|
fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
|
||||||
val normalisedModifier = modifier.orEmpty().removePrefix(":")
|
val normalisedModifier = modifier.orEmpty().removePrefix(":")
|
||||||
|
|
@ -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) {
|
annotation class NativeLibrary(val name: String) {
|
||||||
companion object {
|
companion object {
|
||||||
const val LIB_NAME_ACCESS_CONTROL = "pipebomb"
|
const val LIB_NAME_NATIVE_MARKDOWN = "stendal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object NativeLibraries {
|
object NativeLibraries {
|
||||||
fun init() {
|
fun init() {
|
||||||
System.loadLibrary(NativeLibrary.LIB_NAME_ACCESS_CONTROL)
|
System.loadLibrary(NativeLibrary.LIB_NAME_NATIVE_MARKDOWN)
|
||||||
|
Stendal.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.view.accessibility.AccessibilityManager
|
import android.view.accessibility.AccessibilityManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
|
@ -72,7 +69,6 @@ import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.dialog
|
import androidx.navigation.compose.dialog
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import chat.revolt.BuildConfig
|
|
||||||
import chat.revolt.R
|
import chat.revolt.R
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.internals.ChannelUtils
|
import chat.revolt.api.internals.ChannelUtils
|
||||||
|
|
@ -82,9 +78,6 @@ import chat.revolt.api.realtime.RealtimeSocket
|
||||||
import chat.revolt.api.routes.server.fetchMembers
|
import chat.revolt.api.routes.server.fetchMembers
|
||||||
import chat.revolt.api.schemas.ChannelType
|
import chat.revolt.api.schemas.ChannelType
|
||||||
import chat.revolt.api.schemas.User
|
import chat.revolt.api.schemas.User
|
||||||
import chat.revolt.api.settings.ClosedBetaAccessControlVariates
|
|
||||||
import chat.revolt.api.settings.FeatureFlag
|
|
||||||
import chat.revolt.api.settings.FeatureFlags
|
|
||||||
import chat.revolt.api.settings.SyncedSettings
|
import chat.revolt.api.settings.SyncedSettings
|
||||||
import chat.revolt.callbacks.Action
|
import chat.revolt.callbacks.Action
|
||||||
import chat.revolt.callbacks.ActionChannel
|
import chat.revolt.callbacks.ActionChannel
|
||||||
|
|
@ -97,7 +90,6 @@ import chat.revolt.components.screens.chat.drawer.server.DrawerServer
|
||||||
import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon
|
import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon
|
||||||
import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator
|
import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator
|
||||||
import chat.revolt.internals.Changelogs
|
import chat.revolt.internals.Changelogs
|
||||||
import chat.revolt.ndk.Pipebomb
|
|
||||||
import chat.revolt.persistence.KVStorage
|
import chat.revolt.persistence.KVStorage
|
||||||
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
|
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
|
||||||
import chat.revolt.screens.chat.dialogs.safety.ReportServerDialog
|
import chat.revolt.screens.chat.dialogs.safety.ReportServerDialog
|
||||||
|
|
@ -121,7 +113,6 @@ import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import io.sentry.Sentry
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -228,42 +219,6 @@ class ChatRouterViewModel @Inject constructor(
|
||||||
fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) {
|
fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) {
|
||||||
if (!pure) setSaveCurrentChannel(channelId)
|
if (!pure) setSaveCurrentChannel(channelId)
|
||||||
|
|
||||||
@FeatureFlag("ClosedBetaAccessControl")
|
|
||||||
if (RevoltAPI.channelCache.size > 0 &&
|
|
||||||
FeatureFlags.closedBetaAccessControl is ClosedBetaAccessControlVariates.Restricted &&
|
|
||||||
(FeatureFlags.closedBetaAccessControl as ClosedBetaAccessControlVariates.Restricted)
|
|
||||||
.predicate()
|
|
||||||
.not()
|
|
||||||
) {
|
|
||||||
Pipebomb.incrementHardCrashCounter()
|
|
||||||
|
|
||||||
navController.navigate("no_current_channel") {
|
|
||||||
navController.graph.startDestinationRoute?.let { route ->
|
|
||||||
popUpTo(route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Pipebomb.checkHardCrash()) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"You do not have access to the closed beta.",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
Sentry.init("") // we are about to crash on purpose, let's not send this to Sentry
|
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_DELETE)
|
|
||||||
.setData(Uri.parse("package:${BuildConfig.APPLICATION_ID}"))
|
|
||||||
.setFlags(
|
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
)
|
|
||||||
context.startActivity(
|
|
||||||
intent
|
|
||||||
) // i'm just messing with the user at this point, they know what they did
|
|
||||||
|
|
||||||
Pipebomb.doHardCrash()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Navigate as normal
|
|
||||||
navController.navigate("channel/$channelId") {
|
navController.navigate("channel/$channelId") {
|
||||||
navController.graph.startDestinationRoute?.let { route ->
|
navController.graph.startDestinationRoute?.let { route ->
|
||||||
popUpTo(route)
|
popUpTo(route)
|
||||||
|
|
@ -274,7 +229,6 @@ class ChatRouterViewModel @Inject constructor(
|
||||||
setSaveCurrentRoute("channel/$channelId")
|
setSaveCurrentRoute("channel/$channelId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun navigateToSpecial(destination: String, navController: NavController) {
|
fun navigateToSpecial(destination: String, navController: NavController) {
|
||||||
navController.navigate(destination) {
|
navController.navigate(destination) {
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,15 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconToggleButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
|
@ -131,8 +128,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
|
||||||
message = message.copy(
|
message = message.copy(
|
||||||
tail = false,
|
tail = false,
|
||||||
masquerade = null
|
masquerade = null
|
||||||
),
|
)
|
||||||
truncate = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -83,17 +82,9 @@ import chat.revolt.components.screens.chat.AttachmentManager
|
||||||
import chat.revolt.components.screens.chat.ChannelHeader
|
import chat.revolt.components.screens.chat.ChannelHeader
|
||||||
import chat.revolt.components.screens.chat.ReplyManager
|
import chat.revolt.components.screens.chat.ReplyManager
|
||||||
import chat.revolt.components.screens.chat.TypingIndicator
|
import chat.revolt.components.screens.chat.TypingIndicator
|
||||||
import chat.revolt.internals.markdown.MarkdownContext
|
|
||||||
import chat.revolt.internals.markdown.MarkdownParser
|
|
||||||
import chat.revolt.internals.markdown.MarkdownState
|
|
||||||
import chat.revolt.internals.markdown.addRevoltRules
|
|
||||||
import chat.revolt.internals.markdown.createCodeRule
|
|
||||||
import chat.revolt.internals.markdown.createInlineCodeRule
|
|
||||||
import chat.revolt.sheets.ChannelInfoSheet
|
import chat.revolt.sheets.ChannelInfoSheet
|
||||||
import chat.revolt.sheets.MessageContextSheet
|
import chat.revolt.sheets.MessageContextSheet
|
||||||
import chat.revolt.sheets.ReactSheet
|
import chat.revolt.sheets.ReactSheet
|
||||||
import com.discord.simpleast.core.simple.SimpleMarkdownRules
|
|
||||||
import com.discord.simpleast.core.simple.SimpleRenderer
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -115,8 +106,6 @@ fun ChannelScreen(
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val codeBlockColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
|
|
||||||
|
|
||||||
var channelInfoSheetShown by remember { mutableStateOf(false) }
|
var channelInfoSheetShown by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var messageContextSheetShown by remember { mutableStateOf(false) }
|
var messageContextSheetShown by remember { mutableStateOf(false) }
|
||||||
|
|
@ -338,49 +327,6 @@ fun ChannelScreen(
|
||||||
message.system != null -> SystemMessage(message)
|
message.system != null -> SystemMessage(message)
|
||||||
else -> Message(
|
else -> Message(
|
||||||
message,
|
message,
|
||||||
parse = {
|
|
||||||
val parser = MarkdownParser()
|
|
||||||
.addRules(
|
|
||||||
SimpleMarkdownRules.createEscapeRule()
|
|
||||||
)
|
|
||||||
.addRevoltRules(context)
|
|
||||||
.addRules(
|
|
||||||
createCodeRule(context, codeBlockColor.toArgb()),
|
|
||||||
createInlineCodeRule(
|
|
||||||
context,
|
|
||||||
codeBlockColor.toArgb()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addRules(
|
|
||||||
SimpleMarkdownRules.createSimpleMarkdownRules(
|
|
||||||
includeEscapeRule = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
SimpleRenderer.render(
|
|
||||||
source = it.content ?: "",
|
|
||||||
parser = parser,
|
|
||||||
initialState = MarkdownState(0),
|
|
||||||
renderContext = MarkdownContext(
|
|
||||||
memberMap = viewModel.activeChannel?.server?.let { serverId ->
|
|
||||||
RevoltAPI.members.markdownMemberMapFor(
|
|
||||||
serverId
|
|
||||||
)
|
|
||||||
} ?: mapOf(),
|
|
||||||
userMap = RevoltAPI.userCache.toMap(),
|
|
||||||
channelMap = RevoltAPI.channelCache.mapValues { ch ->
|
|
||||||
ch.value.name ?: ch.value.id
|
|
||||||
?: "#DeletedChannel"
|
|
||||||
},
|
|
||||||
emojiMap = RevoltAPI.emojiCache,
|
|
||||||
serverId = channel?.server ?: "",
|
|
||||||
// check if message consists solely of one *or more* custom emotes
|
|
||||||
useLargeEmojis = it.content?.matches(
|
|
||||||
Regex("(:([0-9A-Z]{26}):)+")
|
|
||||||
) == true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onMessageContextMenu = {
|
onMessageContextMenu = {
|
||||||
messageContextSheetShown = true
|
messageContextSheetShown = true
|
||||||
messageContextSheetTarget = message.id ?: ""
|
messageContextSheetTarget = message.id ?: ""
|
||||||
|
|
|
||||||
|
|
@ -126,15 +126,6 @@ fun LabsHomeScreen(navController: NavController) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Divider()
|
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.FeatureFlags
|
||||||
import chat.revolt.api.settings.LabsAccessControlVariates
|
import chat.revolt.api.settings.LabsAccessControlVariates
|
||||||
import chat.revolt.screens.labs.ui.mockups.CallScreenMockup
|
import chat.revolt.screens.labs.ui.mockups.CallScreenMockup
|
||||||
import chat.revolt.screens.labs.ui.mockups.XMLMessageMockup
|
|
||||||
|
|
||||||
annotation class LabsFeature
|
annotation class LabsFeature
|
||||||
|
|
||||||
|
|
@ -66,10 +65,6 @@ fun LabsRootScreen(topNav: NavController) {
|
||||||
composable("mockups/call") {
|
composable("mockups/call") {
|
||||||
CallScreenMockup()
|
CallScreenMockup()
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("mockups/xmlmessage") {
|
|
||||||
XMLMessageMockup()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.routes.auth.logoutSessionById
|
||||||
import chat.revolt.api.schemas.Session
|
import chat.revolt.api.schemas.Session
|
||||||
import chat.revolt.components.generic.ListHeader
|
import chat.revolt.components.generic.ListHeader
|
||||||
import chat.revolt.components.generic.UIMarkdown
|
import chat.revolt.components.markdown.RichMarkdown
|
||||||
import chat.revolt.components.settings.sessions.SessionItem
|
import chat.revolt.components.settings.sessions.SessionItem
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -201,12 +201,12 @@ fun SessionSettingsScreen(
|
||||||
}
|
}
|
||||||
} ?: run {
|
} ?: run {
|
||||||
item(key = "noCurrentSession") {
|
item(key = "noCurrentSession") {
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
text = stringResource(id = R.string.settings_sessions_this_device_unavailable),
|
input = stringResource(id = R.string.settings_sessions_this_device_unavailable),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.padding(10.dp)
|
.padding(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -325,8 +325,7 @@ fun MessageContextSheet(
|
||||||
message = message.copy(
|
message = message.copy(
|
||||||
tail = false,
|
tail = false,
|
||||||
masquerade = null
|
masquerade = null
|
||||||
),
|
)
|
||||||
truncate = true
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import chat.revolt.R
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.routes.server.leaveOrDeleteServer
|
import chat.revolt.api.routes.server.leaveOrDeleteServer
|
||||||
import chat.revolt.components.generic.SheetClickable
|
import chat.revolt.components.generic.SheetClickable
|
||||||
import chat.revolt.components.generic.UIMarkdown
|
import chat.revolt.components.markdown.RichMarkdown
|
||||||
import chat.revolt.components.screens.settings.ServerOverview
|
import chat.revolt.components.screens.settings.ServerOverview
|
||||||
import chat.revolt.internals.Platform
|
import chat.revolt.internals.Platform
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -151,8 +151,8 @@ fun ServerContextSheet(
|
||||||
modifier = Modifier.padding(vertical = 14.dp)
|
modifier = Modifier.padding(vertical = 14.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
UIMarkdown(
|
RichMarkdown(
|
||||||
text = if (server.description?.isBlank() == false) {
|
input = if (server.description?.isBlank() == false) {
|
||||||
server.description
|
server.description
|
||||||
} else {
|
} else {
|
||||||
stringResource(
|
stringResource(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -29,8 +32,8 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.revolt.R
|
import chat.revolt.R
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.internals.ULID
|
|
||||||
import chat.revolt.api.internals.BrushCompat
|
import chat.revolt.api.internals.BrushCompat
|
||||||
|
import chat.revolt.api.internals.ULID
|
||||||
import chat.revolt.api.internals.solidColor
|
import chat.revolt.api.internals.solidColor
|
||||||
import chat.revolt.api.routes.user.fetchUserProfile
|
import chat.revolt.api.routes.user.fetchUserProfile
|
||||||
import chat.revolt.api.schemas.Profile
|
import chat.revolt.api.schemas.Profile
|
||||||
|
|
@ -38,7 +41,7 @@ import chat.revolt.components.chat.RoleListEntry
|
||||||
import chat.revolt.components.chat.UserBadgeList
|
import chat.revolt.components.chat.UserBadgeList
|
||||||
import chat.revolt.components.chat.UserBadgeRow
|
import chat.revolt.components.chat.UserBadgeRow
|
||||||
import chat.revolt.components.generic.NonIdealState
|
import chat.revolt.components.generic.NonIdealState
|
||||||
import chat.revolt.components.generic.WebMarkdown
|
import chat.revolt.components.markdown.RichMarkdown
|
||||||
import chat.revolt.components.screens.settings.RawUserOverview
|
import chat.revolt.components.screens.settings.RawUserOverview
|
||||||
import chat.revolt.components.screens.settings.UserButtons
|
import chat.revolt.components.screens.settings.UserButtons
|
||||||
import chat.revolt.components.sheets.SheetTile
|
import chat.revolt.components.sheets.SheetTile
|
||||||
|
|
@ -252,10 +255,9 @@ fun UserInfoSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
WebMarkdown(
|
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
text = profile?.content!!,
|
RichMarkdown(input = profile?.content!!)
|
||||||
maskLoading = true
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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