diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28af480..f425c23 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt index 263edd7..659ef92 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt @@ -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 """ + + + + + + + + ${content.sanitizeHTML()} + + + """.trimIndent() +} + +private fun colorToCss(color: Color): String { + val argb = color.toArgb() + return String.format("#%06X", 0xFFFFFF and argb) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 095d61e..bca5fde 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }