wip: feat: add formated text display
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
/*
|
||||
* Created by sweetbread
|
||||
* Copyright (c) 2025. All rights reserved.
|
||||
* Last modified 03.03.2025, 16:46
|
||||
*/
|
||||
|
||||
plugins {
|
||||
@@ -86,5 +85,6 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.navigation.testing)
|
||||
|
||||
// Others
|
||||
implementation(libs.splitties.base) // Syntax sugar
|
||||
implementation(libs.splitties.base) // Syntax sugar
|
||||
implementation(libs.jsoup) // HTML parser
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
/*
|
||||
* Created by sweetbread
|
||||
* Copyright (c) 2025. All rights reserved.
|
||||
* Last modified 03.03.2025, 21:30
|
||||
*/
|
||||
|
||||
package ru.risdeveau.pixeldragon.ui.layout
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,9 +20,11 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -26,11 +33,17 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.safety.Safelist
|
||||
import ru.risdeveau.pixeldragon.api.Event
|
||||
import ru.risdeveau.pixeldragon.api.getAccountData
|
||||
import ru.risdeveau.pixeldragon.api.getEventsAround
|
||||
@@ -67,6 +80,7 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventItem(event: Event) {
|
||||
Box (Modifier.fillMaxWidth()) {
|
||||
@@ -82,9 +96,225 @@ fun EventItem(event: Event) {
|
||||
.padding(4.dp)
|
||||
) {
|
||||
Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
|
||||
Text(event.content.getString("body"))
|
||||
|
||||
when (event.content.optString("format")) {
|
||||
"org.matrix.custom.html" ->
|
||||
HtmlRenderer(event.content.getString("formatted_body"))
|
||||
|
||||
else -> Text(event.content.getString("body"))
|
||||
}
|
||||
}
|
||||
else -> Text(event.type, Modifier.padding(4.dp).background(MaterialTheme.colorScheme.errorContainer).padding(4.dp))
|
||||
else -> Text(event.type,
|
||||
Modifier
|
||||
.padding(4.dp)
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sanitizeHTML(): String {
|
||||
val matrixSafelist = Safelist()
|
||||
.addTags(
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"b", "i", "u", "strong", "s", "del",
|
||||
"sup", "sub", "code",
|
||||
"table", "thead", "tbody",
|
||||
"tr", "th", "td", "ul", "ol", "li",
|
||||
"blockquote", "details", "summary",
|
||||
"em", "code", "div", "pre", "span", "img"
|
||||
)
|
||||
// .addAttributes("span",
|
||||
// "data-mx-bg-color", "data-mx-color",
|
||||
// "data-mx-spoiler", "data-mx-maths"
|
||||
// )
|
||||
.addAttributes("a",
|
||||
"target", "href"
|
||||
)
|
||||
.addAttributes("img",
|
||||
"width", "height", "alt", "title", "src"
|
||||
)
|
||||
.addAttributes("ol", "start")
|
||||
.addAttributes("code", "class")
|
||||
.addAttributes("div", "data-mx-maths")
|
||||
|
||||
val doc = Jsoup.parse(this)
|
||||
doc.select("mx-reply").remove()
|
||||
|
||||
val out = Jsoup.clean(doc.body().wholeText(), matrixSafelist)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun HtmlRenderer(
|
||||
htmlContent: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val webView = remember { WebView(context).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = false
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
}
|
||||
isVerticalScrollBarEnabled = false
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
} }
|
||||
|
||||
val css = """
|
||||
body {
|
||||
font-family: -apple-system, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: bold;
|
||||
padding: 0;
|
||||
}
|
||||
h1 { font-size: 24px; }
|
||||
h2 { font-size: 22px; }
|
||||
h3 { font-size: 20px; }
|
||||
h4 { font-size: 18px; }
|
||||
h5 { font-size: 16px; }
|
||||
h6 { font-size: 14px; }
|
||||
a {
|
||||
color: ${colorToCss(MaterialTheme.colorScheme.primary)};
|
||||
text-decoration: none;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid ${colorToCss(MaterialTheme.colorScheme.outline)};
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
|
||||
font-weight: bold;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)};
|
||||
padding-left: 16px;
|
||||
margin-left: 0;
|
||||
color: ${colorToCss(MaterialTheme.colorScheme.onSurfaceVariant)};
|
||||
}
|
||||
pre {
|
||||
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
code {
|
||||
font-family: monospace;
|
||||
background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)};
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
|
||||
margin: 24px 0;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 24px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
details {
|
||||
margin: 12px 0;
|
||||
}
|
||||
summary {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--border-color: ${colorToCss(MaterialTheme.colorScheme.outline)};
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
LaunchedEffect(htmlContent) {
|
||||
webView.loadDataWithBaseURL(
|
||||
null,
|
||||
wrapHtml(htmlContent, css),
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(webView) {
|
||||
onDispose {
|
||||
webView.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { webView },
|
||||
modifier = modifier,
|
||||
update = { view ->
|
||||
view.webViewClient = SafeWebViewClient(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private class SafeWebViewClient(
|
||||
private val context: Context
|
||||
) : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): Boolean {
|
||||
val url = request.url.toString()
|
||||
try {
|
||||
// Открываем ссылки во внешнем браузере
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
// Обработка ошибок открытия ссылки
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun wrapHtml(content: String, css: String): String {
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
$css
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${content.sanitizeHTML()}
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun colorToCss(color: Color): String {
|
||||
val argb = color.toArgb()
|
||||
return String.format("#%06X", 0xFFFFFF and argb)
|
||||
}
|
||||
Reference in New Issue
Block a user