wip: feat: add formated text display

This commit is contained in:
2025-06-05 00:34:26 +03:00
parent 26417b8072
commit 7a2567f019
3 changed files with 237 additions and 5 deletions
+1 -1
View File
@@ -1,7 +1,6 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 03.03.2025, 16:46
*/
plugins {
@@ -87,4 +86,5 @@ dependencies {
// Others
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)
}
+2
View File
@@ -1,6 +1,7 @@
[versions]
agp = "8.7.3"
coil = "3.1.0"
jsoup = "1.20.1"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
@@ -28,6 +29,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }