This commit is contained in:
2026-02-20 17:28:12 +03:00
parent 1cfad2ca4e
commit 8d6a76ccb5
4 changed files with 460 additions and 463 deletions
@@ -34,6 +34,7 @@ lateinit var token: String
object AccountData : Preferences("system_parameters") {
var token by stringOrNullPref("token", null)
var userId by stringOrNullPref("user_id", null)
var homeserver by stringOrNullPref("homeserver", null)
var syncLastBatch by stringOrNullPref("next_batch", null)
var filter by stringOrNullPref("filter", null)
@@ -15,6 +15,7 @@ import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentLength
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONObject
import ru.risdeveau.pixeldragon.AccountData
import ru.risdeveau.pixeldragon.AccountData.syncLastBatch
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
@@ -48,18 +50,28 @@ class MatrixSyncService {
// }
fun startSync() {
if (AccountData.token == null)
Log.wtf("MatrixSyncService", "Token is null")
if (AccountData.userId == null)
Log.wtf("MatrixSyncService", "User ID is null")
if (AccountData.homeserver == null)
Log.wtf("MatrixSyncService", "Homeserver is null")
Log.i("MatrixSyncService", "Start syncing")
// if (!isInitialized)
// throw IllegalStateException("Sync service not initialized")
if (syncJob?.isActive == true) return
syncJob = CoroutineScope(Dispatchers.IO).launch {
if (syncLastBatch == "") {
if (syncLastBatch == null) {
Log.i("MatrixSyncService", "Init syncing")
_syncState.value = SyncState.Syncing
try {
processSyncResponse(initialSync())
initialSync()
_syncState.value = SyncState.Idle
} catch (e: Exception) {
Log.e("MatrixSyncService", "Initial sync error", e)
_syncState.value = SyncState.Error(e.message)
}
}
@@ -101,44 +113,44 @@ class MatrixSyncService {
fun isSyncActive(): Boolean = syncJob?.isActive == true
private suspend fun processSyncResponse(response: JSONObject) {
// Log.d("syncResponse", response.toString(2))
// val newMessages = mutableListOf<Message>()
// val roomUpdates = mutableListOf<RoomUpdate>()
//
// response.rooms?.join?.forEach { (roomId, roomData) ->
// // Process timeline events (messages)
// roomData.timeline?.events?.forEach { event ->
// val message = event.toMessage(roomId)
// database.messageDao().insertMessage(message)
// newMessages.add(message)
// }
//
// // Process room state updates
// roomData.state?.events?.forEach { event ->
// when (event.type) {
// "m.room.name", "m.room.avatar" -> {
// roomUpdates.add(RoomUpdate(roomId, event.type, event.content))
// }
// }
// }
//
// // Process ephemeral events (typing, receipts)
// roomData.ephemeral?.events?.forEach { event ->
// when (event.type) {
// "m.typing" -> handleTypingEvent(roomId, event)
// "m.receipt" -> handleReceiptEvent(roomId, event)
// }
// }
// }
//
// // Notify the app about new data
// if (newMessages.isNotEmpty()) {
// _newMessages.emit(newMessages)
// }
// if (roomUpdates.isNotEmpty()) {
// _roomUpdates.emit(roomUpdates)
// }
private suspend fun parseSyncResponse(response: JSONObject) {
Log.v("syncResponse", response.toString(2))
val newMessages = mutableListOf<Message>()
val roomUpdates = mutableListOf<RoomUpdate>()
response.rooms?.join?.forEach { (roomId, roomData) ->
// Process timeline events (messages)
roomData.timeline?.events?.forEach { event ->
val message = event.toMessage(roomId)
database.messageDao().insertMessage(message)
newMessages.add(message)
}
// Process room state updates
roomData.state?.events?.forEach { event ->
when (event.type) {
"m.room.name", "m.room.avatar" -> {
roomUpdates.add(RoomUpdate(roomId, event.type, event.content))
}
}
}
// Process ephemeral events (typing, receipts)
roomData.ephemeral?.events?.forEach { event ->
when (event.type) {
"m.typing" -> handleTypingEvent(roomId, event)
"m.receipt" -> handleReceiptEvent(roomId, event)
}
}
}
// Notify the app about new data
if (newMessages.isNotEmpty()) {
_newMessages.emit(newMessages)
}
if (roomUpdates.isNotEmpty()) {
_roomUpdates.emit(roomUpdates)
}
}
}
@@ -156,7 +168,14 @@ suspend fun sync(): JSONObject {
}
suspend fun initialSync(): JSONObject {
val initialFilter = """
fetchRoomMeta()
}
/**
* Fetch rooms metadata during initial sync
*/
suspend fun fetchRoomMeta() {
val filterId = getFilterId("""
{
"room": {
"state": {
@@ -164,62 +183,27 @@ suspend fun initialSync(): JSONObject {
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.encryption",
"m.room.tombstone",
"m.room.power_levels",
"m.room.member"
],
"lazy_load_members": true,
"not_types": []
"lazy_load_members": true
},
"timeline": {
"limit": 10,
"types": ["m.room.message"],
"not_types": [
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.encryption",
"m.room.tombstone",
"m.room.power_levels",
"m.room.member",
"m.call.*"
]
"limit": 0,
"types": []
},
"ephemeral": {
"types": [],
"not_types": ["m.typing", "m.receipt"]
"types": []
},
"include_leave": false
},
"presence": {
"types": [],
"not_types": ["*"]
},
"event_format": "client",
"event_fields": [
"type",
"content",
"sender",
"state_key",
"room_id",
"origin_server_ts"
]
"types": []
}
""".trimIndent()
var r = client.post("$baseUrl/user/${ME!!.userId}/filter") {
setBody(initialFilter)
contentType(ContentType.Application.Json)
bearerAuth(token)
}
if (r.status != HttpStatusCode.OK)
throw IllegalStateException("Failed to create a filter")
val filterId = JSONObject(r.bodyAsText()).getString("filter_id")
""".trimIndent())
// val filterId = "vmNk"
/*val*/ r = client.get("$baseUrl/sync") {
val r = client.get("$baseUrl/sync") {
bearerAuth(token)
url {
parameter("filter", filterId)
@@ -228,10 +212,31 @@ suspend fun initialSync(): JSONObject {
if (r.status != HttpStatusCode.OK)
throw IllegalStateException("Failed to sync")
Log.d("initialSync", "Response size: ${r.bodyAsText().length}")
Log.v("initialSync", "Response size: ${r.contentLength()}")
r.contentLength()?.let {
if (it >= 50*1024*1024)
Log.w("initialSync", "Response size is too large")
}
val json = JSONObject(r.bodyAsText())
syncLastBatch = json.getString("next_batch")
return json
}
/**
* Send a filter to the server and get its id
* @param filter JSON Filter
* @return Filter ID
*/
suspend fun getFilterId(filter: String): String {
val userId = AccountData.userId
if (userId == null)
Log.wtf("getFilter", "user_id is not defined")
val r = client.post("$baseUrl/user/$userId/filter") {
setBody(filter)
contentType(ContentType.Application.Json)
bearerAuth(token)
}
return JSONObject(r.bodyAsText()).getString("filter_id")
}
@@ -26,22 +26,29 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.AccountData
import ru.risdeveau.pixeldragon.api.Me
import ru.risdeveau.pixeldragon.api.getMe
import ru.risdeveau.pixeldragon.initCheck
import ru.risdeveau.pixeldragon.syncService
import ru.risdeveau.pixeldragon.ui.layout.Room
//import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import splitties.activities.start
var ME: Me? = null
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (AccountData.token == null) {
start<Login>()
finish()
} else {
syncService.startSync()
}
enableEdgeToEdge()
setContent {
PixelDragonTheme {
@@ -61,22 +68,6 @@ class MainActivity : ComponentActivity() {
) { innerPadding ->
val navController = rememberNavController()
LaunchedEffect(Unit) {
if (initCheck()) {
ME = withContext(Dispatchers.IO) { getMe() }
if (ME != null) {
syncService.startSync()
navController.navigate("rooms")
} else {
start<Login>()
finish()
}
} else {
start<Login>()
finish()
}
}
NavHost(navController = navController, startDestination = "none") {
composable("none") { }
composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) }
@@ -84,9 +75,9 @@ class MainActivity : ComponentActivity() {
"room/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry ->
Room(Modifier
.padding(innerPadding)
.fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
// Room(Modifier
// .padding(innerPadding)
// .fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
}
composable(
"space/{rid}",
@@ -1,342 +1,342 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
*/
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
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.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
import ru.risdeveau.pixeldragon.ui.activity.ME
import ru.risdeveau.pixeldragon.ui.item.MXCImage
@Composable
fun Room(modifier: Modifier = Modifier, rid: String) {
var eventsId by remember { mutableStateOf(listOf<Event>()) }
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
val readMark = getAccountData(ME!!.userId, rid, "m.fully_read")
val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check
eventsId = eventsAround.let {
it.before + listOf(it.base) + it.after
}
}
}
LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
items(eventsId.reversed()) { event ->
EventItem(event)
}
item {
if (eventsId.isEmpty()) {
Text("Empty room")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventItem(event: Event) {
Box (Modifier.fillMaxWidth()) {
when (event.type) {
"m.room.message" -> Column(
Modifier
.fillMaxSize()
.then(
if (event.sender != ME!!.userId)
Modifier.padding(end = 16.dp)
else
Modifier.padding(start = 16.dp)
)
.padding(4.dp)
.background(
if (event.sender != ME?.userId)
MaterialTheme.colorScheme.surfaceContainer
else
MaterialTheme.colorScheme.primaryContainer
)
.clip(RoundedCornerShape(16.dp))
.padding(4.dp)
) {
Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
when (val msgtype = event.content.optString("msgtype", null)) {
"m.text" -> when (event.content.optString("format")) {
"org.matrix.custom.html" -> {
if (event.content.getString("body") == event.content.getString("formatted_body"))
Text(event.content.getString("body"))
HtmlRenderer(event.content.getString("formatted_body"))
}
else -> Text(event.content.getString("body"))
}
"m.image" ->
MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f))
null -> Text(event.content.toString(2))
else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error)
}
}
else -> Text(event.type,
Modifier
.fillMaxHeight()
.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"
///*
// * Created by sweetbread
// * Copyright (c) 2025. All rights reserved.
// */
//
//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
//import androidx.compose.foundation.layout.fillMaxHeight
//import androidx.compose.foundation.layout.fillMaxSize
//import androidx.compose.foundation.layout.fillMaxWidth
//import androidx.compose.foundation.layout.padding
//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
//import androidx.compose.runtime.remember
//import androidx.compose.runtime.setValue
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.draw.clip
//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.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
//import ru.risdeveau.pixeldragon.ui.activity.ME
//import ru.risdeveau.pixeldragon.ui.item.MXCImage
//
//@Composable
//fun Room(modifier: Modifier = Modifier, rid: String) {
// var eventsId by remember { mutableStateOf(listOf<Event>()) }
// val listState = rememberLazyListState()
//
// LaunchedEffect(Unit) {
// withContext(Dispatchers.IO) {
// val readMark = getAccountData(ME!!.userId, rid, "m.fully_read")
// val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check
// eventsId = eventsAround.let {
// it.before + listOf(it.base) + it.after
// }
// }
// }
//
// LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
// items(eventsId.reversed()) { event ->
// EventItem(event)
// }
//
// item {
// if (eventsId.isEmpty()) {
// Text("Empty room")
// }
// }
// }
//}
//
//@OptIn(ExperimentalMaterial3Api::class)
//@Composable
//fun EventItem(event: Event) {
// Box (Modifier.fillMaxWidth()) {
// when (event.type) {
// "m.room.message" -> Column(
// Modifier
// .fillMaxSize()
// .then(
// if (event.sender != ME!!.userId)
// Modifier.padding(end = 16.dp)
// else
// Modifier.padding(start = 16.dp)
// )
.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().toString(), 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)
}
// .padding(4.dp)
// .background(
// if (event.sender != ME?.userId)
// MaterialTheme.colorScheme.surfaceContainer
// else
// MaterialTheme.colorScheme.primaryContainer
// )
// .clip(RoundedCornerShape(16.dp))
// .padding(4.dp)
// ) {
// Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
//
// when (val msgtype = event.content.optString("msgtype", null)) {
// "m.text" -> when (event.content.optString("format")) {
// "org.matrix.custom.html" -> {
// if (event.content.getString("body") == event.content.getString("formatted_body"))
// Text(event.content.getString("body"))
// HtmlRenderer(event.content.getString("formatted_body"))
// }
//
// else -> Text(event.content.getString("body"))
// }
//
// "m.image" ->
// MXCImage(event.content.getString("url"), Modifier.fillMaxWidth(.9f))
//
// null -> Text(event.content.toString(2))
//
// else -> Text("Unknown type: $msgtype", color = MaterialTheme.colorScheme.error)
// }
//
// }
//
// else -> Text(event.type,
// Modifier
// .fillMaxHeight()
// .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().toString(), 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)
//}