wip: show messages

This commit is contained in:
2026-03-20 17:04:26 +03:00
parent f8b10ebb34
commit a91bbaf129
2 changed files with 369 additions and 341 deletions
@@ -1,342 +1,367 @@
///*
// * 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"
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
*/
package ru.risdeveau.pixeldragon.ui.layout
import android.annotation.SuppressLint
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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 de.connect2x.trixnity.client.room
import de.connect2x.trixnity.client.room.toFlowList
import de.connect2x.trixnity.client.store.TimelineEvent
import de.connect2x.trixnity.client.user
import de.connect2x.trixnity.core.model.RoomId
import de.connect2x.trixnity.core.model.events.ClientEvent
import de.connect2x.trixnity.core.model.events.m.room.RoomMessageEventContent
import io.ktor.util.reflect.instanceOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.MXCImage
@Composable
fun Room(modifier: Modifier = Modifier, rid: String) {
val roomId = remember(rid) { RoomId(rid) }
val limit = remember { MutableStateFlow(50) }
val eventsFlow = remember(roomId) {
client!!.room
.getLastTimelineEvents(roomId)
.toFlowList(limit)
}
val eventFlows by eventsFlow.collectAsState(initial = emptyList())
val listState = rememberLazyListState()
LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
items(eventFlows, key = { it.hashCode() }) { eventFlow ->
EventItem(eventFlow)
}
if (eventFlows.isEmpty()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventItem(eventFlow: Flow<TimelineEvent>) {
val event by eventFlow.collectAsState(null)
event?.let { 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.content?.getOrNull().toString(),
Modifier
.fillMaxHeight()
.padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp)
)*/
Text(event.content?.getOrNull().toString())
}
}
}
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("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)
//}
.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)
}
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<Flow<Flow<TimelineEvent>>?>.flattenTimelineEvents(): Flow<TimelineEvent> = this
.filterNotNull()
.flatMapConcat { middleFlow ->
middleFlow.flatMapConcat { innerFlow ->
innerFlow
}
}