From 902af5e7b5e3684329cdaf38b6279c34c4903358 Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Fri, 20 Mar 2026 17:04:26 +0300 Subject: [PATCH] wip: show messages --- .../risdeveau/pixeldragon/ui/layout/Room.kt | 707 +++++++++--------- 1 file changed, 366 insertions(+), 341 deletions(-) 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 dba9067..4846d33 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,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()) } -// 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) { + 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 """ -// -// -// -// -// -// -// -// ${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 + .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 """ + + + + + + + + ${content.sanitizeHTML()} + + + """.trimIndent() +} + +private fun colorToCss(color: Color): String { + val argb = color.toArgb() + return String.format("#%06X", 0xFFFFFF and argb) +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow>?>.flattenTimelineEvents(): Flow = this + .filterNotNull() + .flatMapConcat { middleFlow -> + middleFlow.flatMapConcat { innerFlow -> + innerFlow + } + }