feat: placeholder web based markdown renderer
Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
parent
e1a001ce5c
commit
97d5f367d1
|
|
@ -174,6 +174,7 @@ dependencies {
|
||||||
// Other AndroidX libraries - used for various things and never seem to have a consistent version
|
// Other AndroidX libraries - used for various things and never seem to have a consistent version
|
||||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||||
implementation "androidx.browser:browser:1.6.0"
|
implementation "androidx.browser:browser:1.6.0"
|
||||||
|
implementation "androidx.webkit:webkit:1.7.0"
|
||||||
implementation "androidx.datastore:datastore-preferences:1.1.0-alpha04"
|
implementation "androidx.datastore:datastore-preferences:1.1.0-alpha04"
|
||||||
implementation "androidx.datastore:datastore:1.1.0-alpha04"
|
implementation "androidx.datastore:datastore:1.1.0-alpha04"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
package chat.revolt.components.generic
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.ViewGroup.LayoutParams
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.webkit.WebViewAssetLoader
|
||||||
|
import chat.revolt.activities.InviteActivity
|
||||||
|
import chat.revolt.api.REVOLT_APP
|
||||||
|
import org.intellij.lang.annotations.Language
|
||||||
|
|
||||||
|
// TODO: Obvious placeholder.
|
||||||
|
@Language("HTML")
|
||||||
|
private const val HTML_TEMPLATE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<title></title>
|
||||||
|
<style>
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("/_android_res/font/inter_regular.ttf");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("/_android_res/font/inter_bold.ttf");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono";
|
||||||
|
src: url("/_android_res/font/jetbrainsmono_regular.ttf");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, html {
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: %s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:link, a:visited {
|
||||||
|
color: %s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, code {
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#markdown {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="markdown">%s</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"></script>
|
||||||
|
<script>
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
const converter = new showdown.Converter()
|
||||||
|
|
||||||
|
converter.setFlavor("github")
|
||||||
|
converter.setOption("tables", true)
|
||||||
|
converter.setOption("emoji", true)
|
||||||
|
converter.setOption("disableForced4SpacesIndentedSublists", true)
|
||||||
|
converter.setOption("noHeaderId", true)
|
||||||
|
converter.setOption("simpleLineBreaks", true)
|
||||||
|
converter.setOption("strikethrough", true)
|
||||||
|
converter.setOption("tasklists", true)
|
||||||
|
|
||||||
|
const markdown = document.querySelector("#markdown")
|
||||||
|
const html = converter.makeHtml(markdown.innerHTML)
|
||||||
|
markdown.innerHTML = DOMPurify.sanitize(html)
|
||||||
|
Android.onLoaded()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private fun argbAsCssColour(argb: Int): String {
|
||||||
|
val alpha = (argb shr 24 and 0xff) / 255.0f
|
||||||
|
val red = argb shr 16 and 0xff
|
||||||
|
val green = argb shr 8 and 0xff
|
||||||
|
val blue = argb and 0xff
|
||||||
|
return String.format("#%02x%02x%02x%02x", red, green, blue, (alpha * 255).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebView-backed Markdown renderer that supports all Markdown features
|
||||||
|
* including KaTeX
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun WebMarkdown(
|
||||||
|
text: String,
|
||||||
|
maskLoading: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val contentColour = LocalContentColor.current
|
||||||
|
val materialColourScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
var finishedLoading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (!finishedLoading && maskLoading) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
val cssContentColour = argbAsCssColour(contentColour.toArgb())
|
||||||
|
val cssPrimaryColour = argbAsCssColour(materialColourScheme.primary.toArgb())
|
||||||
|
|
||||||
|
val html = String.format(
|
||||||
|
HTML_TEMPLATE,
|
||||||
|
cssContentColour,
|
||||||
|
cssPrimaryColour,
|
||||||
|
text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
)
|
||||||
|
|
||||||
|
val assetLoader = WebViewAssetLoader.Builder()
|
||||||
|
.setDomain(Uri.parse(REVOLT_APP).host!!)
|
||||||
|
.addPathHandler(
|
||||||
|
"/_android_assets/",
|
||||||
|
WebViewAssetLoader.AssetsPathHandler(context)
|
||||||
|
)
|
||||||
|
.addPathHandler(
|
||||||
|
"/_android_res/",
|
||||||
|
WebViewAssetLoader.ResourcesPathHandler(context)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
webChromeClient = object : WebChromeClient() {}
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldInterceptRequest(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?
|
||||||
|
): WebResourceResponse? {
|
||||||
|
return request?.let { assetLoader.shouldInterceptRequest(it.url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(
|
||||||
|
view: WebView?,
|
||||||
|
webResourceRequest: WebResourceRequest
|
||||||
|
): Boolean {
|
||||||
|
// Capture clicks on invite links
|
||||||
|
if (webResourceRequest.url.host == "rvlt.gg" ||
|
||||||
|
(webResourceRequest.url.host?.endsWith("revolt.chat") == true && webResourceRequest.url.path?.startsWith(
|
||||||
|
"/invite"
|
||||||
|
) == true)
|
||||||
|
) {
|
||||||
|
val intent = Intent(
|
||||||
|
context,
|
||||||
|
InviteActivity::class.java
|
||||||
|
).setAction(Intent.ACTION_VIEW)
|
||||||
|
|
||||||
|
intent.data = webResourceRequest.url
|
||||||
|
context.startActivity(intent)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, open the link in the browser using androidx.browser
|
||||||
|
val customTab = CustomTabsIntent.Builder()
|
||||||
|
.setShowTitle(true)
|
||||||
|
.setDefaultColorSchemeParams(
|
||||||
|
CustomTabColorSchemeParams.Builder()
|
||||||
|
.setToolbarColor(materialColourScheme.background.toArgb())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
customTab.launchUrl(context, webResourceRequest.url)
|
||||||
|
|
||||||
|
// Prevent the WebView from navigating to the URL
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDataWithBaseURL(
|
||||||
|
REVOLT_APP,
|
||||||
|
html,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
"UTF-8",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
setSupportZoom(false)
|
||||||
|
setSupportMultipleWindows(false)
|
||||||
|
isVerticalScrollBarEnabled = false
|
||||||
|
isHorizontalScrollBarEnabled = false
|
||||||
|
cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
|
}
|
||||||
|
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onLoaded() {
|
||||||
|
finishedLoading = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Android"
|
||||||
|
)
|
||||||
|
|
||||||
|
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
LayoutParams.WRAP_CONTENT,
|
||||||
|
LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ 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
|
||||||
import chat.revolt.components.chat.RoleChip
|
import chat.revolt.components.chat.RoleChip
|
||||||
import chat.revolt.components.generic.UIMarkdown
|
import chat.revolt.components.generic.WebMarkdown
|
||||||
import chat.revolt.components.screens.settings.RawUserOverview
|
import chat.revolt.components.screens.settings.RawUserOverview
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
|
@ -107,9 +107,10 @@ fun UserContextSheet(
|
||||||
modifier = Modifier.padding(vertical = 10.dp)
|
modifier = Modifier.padding(vertical = 10.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (profile?.content != null) {
|
if (profile?.content.isNullOrBlank().not()) {
|
||||||
UIMarkdown(
|
WebMarkdown(
|
||||||
text = profile!!.content!!,
|
text = profile!!.content!!,
|
||||||
|
maskLoading = true
|
||||||
)
|
)
|
||||||
} else if (profile != null) {
|
} else if (profile != null) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue