From 0001de312839a9effeafcf4b15ac50933410fa7f Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Fri, 10 Apr 2026 14:26:33 +0300 Subject: [PATCH] Display and send messages --- .idea/vcs.xml | 2 +- app/build.gradle.kts | 2 +- .../pixeldragon/ui/activity/MainActivity.kt | 7 +- .../ru/risdeveau/pixeldragon/ui/item/Image.kt | 6 +- .../risdeveau/pixeldragon/ui/layout/Room.kt | 1219 +++++++++++++---- gradle/libs.versions.toml | 2 + 6 files changed, 976 insertions(+), 262 deletions(-) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100755 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 230ccd4..323033f 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,5 +98,5 @@ dependencies { // Others implementation(libs.splitties.base) // Syntax sugar implementation(libs.jsoup) // HTML parser - + implementation(libs.iconsax.compose) // Material icons } \ No newline at end of file diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt index 7f39915..37fc236 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt @@ -40,6 +40,7 @@ import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac import de.connect2x.trixnity.clientserverapi.client.SyncState import ru.risdeveau.pixeldragon.R import ru.risdeveau.pixeldragon.client +import ru.risdeveau.pixeldragon.ui.layout.Room import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.util.getMediaStore @@ -94,9 +95,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}", diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt index 5d868c8..2665f17 100755 --- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt +++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/item/Image.kt @@ -8,8 +8,7 @@ package ru.risdeveau.pixeldragon.ui.item import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Warning +import io.github.rabehx.iconsax.Iconsax import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -28,6 +27,7 @@ import coil3.request.ImageRequest import kotlinx.coroutines.flow.MutableStateFlow import de.connect2x.trixnity.client.media import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress +import io.github.rabehx.iconsax.outline.Warning2 import ru.risdeveau.pixeldragon.client enum class ImageLoadState { @@ -119,7 +119,7 @@ fun MXCImage( } imageLoadState == ImageLoadState.Error -> { Icon( - Icons.Outlined.Warning, + Iconsax.Outline.Warning2, contentDescription = "Error", modifier = Modifier.align(Alignment.Center) ) 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 4846d33..a14f83a 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,30 +1,47 @@ /* * Created by sweetbread - * Copyright (c) 2025. All rights reserved. + * Copyright (c) 2026. All rights reserved. */ package ru.risdeveau.pixeldragon.ui.layout import android.annotation.SuppressLint -import android.content.Context import android.content.Intent +import android.graphics.Canvas +import android.graphics.Paint import android.net.Uri +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.text.style.LeadingMarginSpan +import android.text.style.QuoteSpan +import android.util.TypedValue +import android.view.MotionEvent +import android.view.ViewGroup +import android.webkit.WebChromeClient import android.webkit.WebResourceRequest +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.TextView import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn 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.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -32,336 +49,1030 @@ 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.alpha 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.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.HtmlCompat +import androidx.core.widget.TextViewCompat import de.connect2x.trixnity.client.room +import de.connect2x.trixnity.client.room.message.text import de.connect2x.trixnity.client.room.toFlowList import de.connect2x.trixnity.client.store.TimelineEvent -import de.connect2x.trixnity.client.user +import de.connect2x.trixnity.client.store.eventId import de.connect2x.trixnity.core.model.RoomId import de.connect2x.trixnity.core.model.events.ClientEvent +import de.connect2x.trixnity.core.model.events.m.room.AvatarEventContent +import de.connect2x.trixnity.core.model.events.m.room.CanonicalAliasEventContent +import de.connect2x.trixnity.core.model.events.m.room.HistoryVisibilityEventContent +import de.connect2x.trixnity.core.model.events.m.room.MemberEventContent +import de.connect2x.trixnity.core.model.events.m.room.NameEventContent +import de.connect2x.trixnity.core.model.events.m.room.PowerLevelsEventContent import de.connect2x.trixnity.core.model.events.m.room.RoomMessageEventContent -import io.ktor.util.reflect.instanceOf +import de.connect2x.trixnity.core.model.events.m.room.TopicEventContent +import io.github.rabehx.iconsax.Iconsax +import io.github.rabehx.iconsax.filled.Send2 +import io.github.rabehx.iconsax.outline.Send2 +import kotlinx.coroutines.CoroutineScope 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 kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import org.jsoup.Jsoup +import org.jsoup.nodes.Element import org.jsoup.safety.Safelist import ru.risdeveau.pixeldragon.client -import ru.risdeveau.pixeldragon.ui.item.MXCImage +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.roundToInt +private const val WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:" + +private val simpleHtmlTags = setOf( + "a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2", + "h3", "h4", "h5", "h6", "i", "li", "ol", "p", "pre", "s", "span", + "strong", "sub", "sup", "u", "ul" +) + +private val measurementBlockTags = setOf( + "blockquote", "details", "div", "h1", "h2", "h3", "h4", "h5", "h6", + "img", "li", "ol", "p", "pre", "summary", "table", "tbody", "thead", + "tr", "td", "th", "ul" +) + +private val complexHtmlHeightCache = mutableMapOf() + +private enum class FormattedMessageKind { + SIMPLE_HTML, + COMPLEX_HTML, +} + +private data class TextBlockMetrics( + val lineHeightDp: Float, + val avgCharWidthDp: Float, + val marginDp: Float, +) + +private val bodyTextMetrics = TextBlockMetrics( + lineHeightDp = 24f, + avgCharWidthDp = 7.2f, + marginDp = 10f, +) + +private val listItemTextMetrics = TextBlockMetrics( + lineHeightDp = 24f, + avgCharWidthDp = 7.2f, + marginDp = 6f, +) + +private val codeBlockTextMetrics = TextBlockMetrics( + lineHeightDp = 20f, + avgCharWidthDp = 8f, + marginDp = 16f, +) + +private val tableCellTextMetrics = TextBlockMetrics( + lineHeightDp = 20f, + avgCharWidthDp = 7f, + marginDp = 8f, +) + +private val headingMetrics = mapOf( + "h1" to TextBlockMetrics(36f, 12f, 20f), + "h2" to TextBlockMetrics(32f, 11f, 18f), + "h3" to TextBlockMetrics(28f, 10f, 16f), + "h4" to TextBlockMetrics(26f, 9f, 14f), + "h5" to TextBlockMetrics(24f, 8.5f, 12f), + "h6" to TextBlockMetrics(22f, 8f, 10f), +) + +@OptIn(ExperimentalCoroutinesApi::class) @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) + .flatMapLatest { flows -> + if (flows.isEmpty()) flowOf(emptyList()) + else combine(flows) { it.toList() } + } } - val eventFlows by eventsFlow.collectAsState(initial = emptyList()) + val events by eventsFlow.collectAsState(initial = emptyList()) val listState = rememberLazyListState() - LazyColumn(modifier = modifier, state = listState, reverseLayout = true) { - items(eventFlows, key = { it.hashCode() }) { eventFlow -> - EventItem(eventFlow) + Column(modifier) { + var message by remember { mutableStateOf("") } + + LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) { + items(events, key = { it.eventId.full }) { event -> + EventItem(event) + } + + if (events.isEmpty()) { + item { + Text("Empty room", modifier = Modifier.padding(16.dp)) + } + } } - if (eventFlows.isEmpty()) { - item { - Text("Empty room", modifier = Modifier.padding(16.dp)) - } + Row(Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier + .padding(4.dp) + .weight(1f), + value = message, + onValueChange = { message = it }, + ) + IconButton( + enabled = message.isNotBlank(), + content = { Icon(if (message.isBlank()) Iconsax.Outline.Send2 else Iconsax.Filled.Send2, contentDescription = "Send") }, + onClick = { + val payload = message.trim() + if (payload.isBlank()) return@IconButton + + CoroutineScope(Dispatchers.IO).launch { + client!!.room.sendMessage(RoomId(rid)) { + text(payload) + } + } + message = "" + } + ) } } } @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(), +fun EventItem(event: TimelineEvent) { + val content = event.content?.getOrNull() + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 24.dp) + ) { + when { + content == null -> Text( + "Not decrypted", Modifier .fillMaxHeight() .padding(4.dp) .background(MaterialTheme.colorScheme.errorContainer) .padding(4.dp) - )*/ - Text(event.content?.getOrNull().toString()) + ) + + content is RoomMessageEventContent -> { + val formatted = content.formattedBody + if (formatted != null) { + FormattedMessageContent( + htmlContent = formatted, + plainText = content.body, + ) + } else { + Text( + text = content.body, + modifier = Modifier.padding(4.dp) + ) + } + } + + event.event is ClientEvent.RoomEvent.StateEvent -> Text( + when (content) { + is AvatarEventContent -> "Avatar changed" + is PowerLevelsEventContent -> "Permissions changed" + is MemberEventContent -> "Membership changed" + is CanonicalAliasEventContent -> "Canonical alias changed" + is TopicEventContent -> "Topic changed" + is NameEventContent -> "Name changed" + is HistoryVisibilityEventContent -> "History visibility changed" + else -> content.toString() + }, + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + else -> Text( + content.toString(), + Modifier + .fillMaxHeight() + .padding(4.dp) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(4.dp) + ) } } } +@Composable +private fun FormattedMessageContent( + htmlContent: String, + plainText: String, + modifier: Modifier = Modifier, +) { + val sanitizedHtml = remember(htmlContent) { htmlContent.sanitizeHTML() } + + when (classifyFormattedMessage(sanitizedHtml)) { + FormattedMessageKind.SIMPLE_HTML -> SimpleHtmlRenderer( + sanitizedHtml = sanitizedHtml, + modifier = modifier, + ) + + FormattedMessageKind.COMPLEX_HTML -> ComplexHtmlRenderer( + sanitizedHtml = sanitizedHtml, + plainText = plainText, + modifier = modifier, + ) + } +} + +private fun classifyFormattedMessage(sanitizedHtml: String): FormattedMessageKind { + val body = Jsoup.parseBodyFragment(sanitizedHtml).body() + if (body.select("[data-mx-maths]").isNotEmpty()) { + return FormattedMessageKind.COMPLEX_HTML + } + + val containsOnlySimpleTags = body + .getAllElements() + .drop(1) + .all { it.tagName() in simpleHtmlTags } + + return if (containsOnlySimpleTags) { + FormattedMessageKind.SIMPLE_HTML + } else { + FormattedMessageKind.COMPLEX_HTML + } +} + 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" + "a", "b", "blockquote", "br", "code", "del", "details", "div", "em", "h1", + "h2", "h3", "h4", "h5", "h6", "i", "img", "li", "ol", "p", "pre", + "s", "span", "strong", "sub", "summary", "sup", "table", "tbody", + "td", "th", "thead", "tr", "u", "ul" ) + .addAttributes("a", "target", "href") + .addAttributes("img", "width", "height", "alt", "title", "src") .addAttributes("ol", "start") .addAttributes("code", "class") .addAttributes("div", "data-mx-maths") + .addProtocols("a", "href", "http", "https", "mailto", "matrix") + .addProtocols("img", "src", "http", "https") val doc = Jsoup.parse(this) doc.select("mx-reply").remove() - val out = Jsoup.clean(doc.body().toString(), matrixSafelist) - return out + return Jsoup.clean(doc.body().toString(), matrixSafelist) } - @Composable -fun HtmlRenderer( - htmlContent: String, - modifier: Modifier = Modifier +private fun SimpleHtmlRenderer( + sanitizedHtml: 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() - } + val density = LocalDensity.current + val bodyTextStyle = MaterialTheme.typography.bodyMedium + val textColor = MaterialTheme.colorScheme.onBackground.toArgb() + val linkColor = MaterialTheme.colorScheme.primary.toArgb() + val quoteStripeColor = MaterialTheme.colorScheme.primary.toArgb() + val quoteStripeWidthPx = with(density) { 4.dp.roundToPx() } + val quoteGapWidthPx = with(density) { 12.dp.roundToPx() } + val fontSizeSp = bodyTextStyle.fontSize.value + val lineHeightPx = with(density) { + resolveLineHeightSp( + fontSizeSp = fontSizeSp, + lineHeightSp = bodyTextStyle.lineHeight.value, + ).sp.roundToPx() } AndroidView( - factory = { webView }, - modifier = modifier, - update = { view -> - view.webViewClient = SafeWebViewClient(context) + modifier = modifier.fillMaxWidth(), + factory = { context -> + TextView(context).apply { + linksClickable = true + movementMethod = LinkMovementMethod.getInstance() + includeFontPadding = false + setTextColor(textColor) + setLinkTextColor(linkColor) + setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSizeSp) + TextViewCompat.setLineHeight(this, lineHeightPx) + } + }, + update = { textView -> + textView.includeFontPadding = false + textView.setTextColor(textColor) + textView.setLinkTextColor(linkColor) + textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSizeSp) + TextViewCompat.setLineHeight(textView, lineHeightPx) + textView.text = HtmlCompat.fromHtml( + sanitizedHtml, + HtmlCompat.FROM_HTML_MODE_COMPACT, + ).withThemedQuoteSpans( + stripeColor = quoteStripeColor, + stripeWidthPx = quoteStripeWidthPx, + gapWidthPx = quoteGapWidthPx, + ) } ) } -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)) +private fun resolveLineHeightSp( + fontSizeSp: Float, + lineHeightSp: Float, +): Float { + return when { + lineHeightSp > 0f -> lineHeightSp + else -> fontSizeSp * 1.4f + } +} + +private fun Spanned.withThemedQuoteSpans( + stripeColor: Int, + stripeWidthPx: Int, + gapWidthPx: Int, +): Spanned { + val builder = SpannableStringBuilder(this) + val quoteSpans = builder.getSpans(0, builder.length, QuoteSpan::class.java) + + for (quoteSpan in quoteSpans) { + val start = builder.getSpanStart(quoteSpan) + val end = builder.getSpanEnd(quoteSpan) + val flags = builder.getSpanFlags(quoteSpan) + + builder.removeSpan(quoteSpan) + builder.setSpan( + ThemedQuoteSpan( + stripeColor = stripeColor, + stripeWidthPx = stripeWidthPx, + gapWidthPx = gapWidthPx, + ), + start, + end, + flags, + ) + } + + return builder +} + +private class ThemedQuoteSpan( + private val stripeColor: Int, + private val stripeWidthPx: Int, + private val gapWidthPx: Int, +) : LeadingMarginSpan { + override fun getLeadingMargin(first: Boolean): Int { + return stripeWidthPx + gapWidthPx + } + + override fun drawLeadingMargin( + c: Canvas, + p: Paint, + x: Int, + dir: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + first: Boolean, + layout: android.text.Layout, + ) { + val previousStyle = p.style + val previousColor = p.color + + p.style = Paint.Style.FILL + p.color = stripeColor + + val stripeLeft = x.toFloat() + val stripeRight = (x + dir * stripeWidthPx).toFloat() + c.drawRect( + minOf(stripeLeft, stripeRight), + top.toFloat(), + maxOf(stripeLeft, stripeRight), + bottom.toFloat(), + p, + ) + + p.style = previousStyle + p.color = previousColor + } +} + +@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") +@Composable +private fun ComplexHtmlRenderer( + sanitizedHtml: String, + plainText: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val density = LocalDensity.current + val bodyTextStyle = MaterialTheme.typography.bodyMedium + val onBackgroundColor = MaterialTheme.colorScheme.onBackground + val primaryColor = MaterialTheme.colorScheme.primary + val outlineColor = MaterialTheme.colorScheme.outline + val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant + val bodyFontSizeCssPx = bodyTextStyle.fontSize.value + val bodyLineHeightCssPx = resolveLineHeightSp( + fontSizeSp = bodyFontSizeCssPx, + lineHeightSp = bodyTextStyle.lineHeight.value, + ) + val textZoom = remember(density.fontScale) { + (density.fontScale * 100).roundToInt().coerceIn(50, 300) + } + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val availableWidthDp = max(maxWidth.value, 120f) + val widthBucket = remember(availableWidthDp) { + (availableWidthDp / 12f).roundToInt().coerceAtLeast(10) + } + val cacheKey = remember(widthBucket, sanitizedHtml) { + "$widthBucket:$sanitizedHtml" + } + + val estimatedHeightPx = remember(sanitizedHtml, plainText, widthBucket) { + with(density) { + estimateComplexMessageHeightDp( + sanitizedHtml = sanitizedHtml, + plainText = plainText, + availableWidthDp = availableWidthDp, + ).dp.roundToPx() + } + } + + var measuredHeightPx by remember(cacheKey) { + mutableStateOf(complexHtmlHeightCache[cacheKey]) + } + var isLoaded by remember(cacheKey) { + mutableStateOf(measuredHeightPx != null) + } + + val reservedHeightPx = measuredHeightPx ?: estimatedHeightPx + val reservedHeightDp = with(density) { reservedHeightPx.toDp() } + val updateThresholdPx = with(density) { 12.dp.roundToPx() } + + val css = remember( + bodyFontSizeCssPx, + bodyLineHeightCssPx, + onBackgroundColor, + primaryColor, + outlineColor, + surfaceVariantColor, + ) { + val onBackgroundCss = colorToCss(onBackgroundColor) + val primaryCss = colorToCss(primaryColor) + val outlineCss = colorToCss(outlineColor) + val surfaceVariantCss = colorToCss(surfaceVariantColor) + + """ + html, body { + background: transparent; + } + body { + font-family: -apple-system, sans-serif; + font-size: ${bodyFontSizeCssPx}px; + line-height: ${bodyLineHeightCssPx}px; + color: $onBackgroundCss; + margin: 0; + padding: 2px 0; + word-wrap: break-word; + overflow-wrap: anywhere; + -webkit-text-size-adjust: 100%; + } + p, li, blockquote, td, th, pre, code { + font-size: inherit; + line-height: inherit; + } + a { + color: $primaryCss; + text-decoration: none; + } + img { + max-width: 100%; + height: auto; + display: block; + margin: 8px 0; + } + table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + } + th, td { + border: 1px solid $outlineCss; + padding: 8px; + text-align: left; + vertical-align: top; + } + blockquote { + border-left: 4px solid $primaryCss; + padding-left: 12px; + margin: 8px 0; + } + pre, code { + background-color: $surfaceVariantCss; + border-radius: 4px; + } + pre { + padding: 8px; + white-space: pre-wrap; + } + code { + padding: 2px 4px; + } + """.trimIndent() + } + val htmlDocument = remember(sanitizedHtml, css) { + wrapHtml(sanitizedContent = sanitizedHtml, css = css) + } + + val onHeightMeasured by rememberUpdatedState<(Int) -> Unit> { rawHeightPx -> + val resolvedHeightPx = rawHeightPx.coerceAtLeast(1) + val currentHeightPx = measuredHeightPx ?: estimatedHeightPx + val shouldUpdate = measuredHeightPx == null || + resolvedHeightPx > currentHeightPx || + abs(resolvedHeightPx - currentHeightPx) > updateThresholdPx + + if (shouldUpdate) { + complexHtmlHeightCache[cacheKey] = resolvedHeightPx + measuredHeightPx = resolvedHeightPx + } + isLoaded = true + } + val currentContext by rememberUpdatedState(context) + + val webView = remember(cacheKey) { + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + reservedHeightPx, + ) + settings.apply { + javaScriptEnabled = true + loadWithOverviewMode = false + useWideViewPort = false + layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL + domStorageEnabled = false + javaScriptCanOpenWindowsAutomatically = false + } + isVerticalScrollBarEnabled = false + overScrollMode = WebView.OVER_SCROLL_NEVER + setBackgroundColor(Color.Transparent.toArgb()) + setOnTouchListener { _, event -> + event.action == MotionEvent.ACTION_MOVE + } + webChromeClient = object : WebChromeClient() { + override fun onReceivedTitle(view: WebView?, title: String?) { + if (view == null || title.isNullOrBlank()) return + if (!title.startsWith(WEBVIEW_HEIGHT_TITLE_PREFIX)) return + + val cssHeight = title + .removePrefix(WEBVIEW_HEIGHT_TITLE_PREFIX) + .toIntOrNull() + ?: return + val heightPx = (cssHeight * view.resources.displayMetrics.density) + .roundToInt() + .coerceAtLeast(1) + + view.layoutParams = view.layoutParams.apply { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = heightPx + } + view.requestLayout() + onHeightMeasured(heightPx) + } + } + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + val currentView = view ?: return + currentView.post { + val fallbackHeightPx = (currentView.contentHeight * + currentView.resources.displayMetrics.density).roundToInt() + if (fallbackHeightPx > 0) { + onHeightMeasured(fallbackHeightPx) + } + } + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + return try { + currentContext.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(request.url.toString())) + ) + true + } catch (_: Exception) { + false + } + } + } + } + } + + LaunchedEffect(webView, htmlDocument) { + isLoaded = measuredHeightPx != null + webView.loadDataWithBaseURL( + null, + htmlDocument, + "text/html", + "UTF-8", + null, + ) + } + + DisposableEffect(webView) { + onDispose { + webView.stopLoading() + webView.destroy() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(reservedHeightDp) + ) { + if (!isLoaded) { + Text( + text = plainText.ifBlank { " " }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .alpha(0.6f), + style = MaterialTheme.typography.bodyMedium, + ) + } + + AndroidView( + factory = { webView }, + modifier = Modifier + .fillMaxWidth() + .height(reservedHeightDp) + .alpha(if (isLoaded) 1f else 0f), + update = { view -> + if (view.settings.textZoom != textZoom) { + view.settings.textZoom = textZoom + } + + val targetHeightPx = measuredHeightPx ?: estimatedHeightPx + if (view.layoutParams.height != targetHeightPx) { + view.layoutParams = view.layoutParams.apply { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = targetHeightPx + } + view.requestLayout() + } + } ) - return true - } catch (e: Exception) { - // Обработка ошибок открытия ссылки - return false } } } -private fun wrapHtml(content: String, css: String): String { +private fun estimateComplexMessageHeightDp( + sanitizedHtml: String, + plainText: String, + availableWidthDp: Float, +): Float { + val body = Jsoup.parseBodyFragment(sanitizedHtml).body() + val bodyChildren = body.children() + + val measuredFromHtml = if (bodyChildren.isEmpty()) { + estimateTextHeightDp( + text = plainText.normalizeForMeasurement(), + availableWidthDp = availableWidthDp, + metrics = bodyTextMetrics, + ) + 12f + } else { + bodyChildren.sumOf { + estimateNodeHeightDp( + element = it, + availableWidthDp = availableWidthDp, + ).toDouble() + }.toFloat() + 4f + } + + val plainFallback = estimateTextHeightDp( + text = plainText.normalizeForMeasurement(), + availableWidthDp = availableWidthDp, + metrics = bodyTextMetrics, + ) + 16f + + return max(measuredFromHtml, plainFallback.coerceAtLeast(72f)) + .coerceIn(96f, 1400f) +} + +private fun estimateNodeHeightDp( + element: Element, + availableWidthDp: Float, +): Float { + val widthDp = availableWidthDp.coerceAtLeast(96f) + return when (element.tagName()) { + "img" -> estimateImageHeightDp(element, widthDp) + "table" -> estimateTableHeightDp(element, widthDp) + "ul", "ol" -> estimateListHeightDp(element, widthDp) + "pre" -> estimatePreformattedHeightDp(element.text(), widthDp) + "blockquote" -> { + val quoteWidthDp = (widthDp - 16f).coerceAtLeast(80f) + estimateContainerHeightDp( + element = element, + availableWidthDp = quoteWidthDp, + defaultMetrics = bodyTextMetrics, + extraPaddingDp = 14f, + ) + } + "details" -> estimateDetailsHeightDp(element, widthDp) + "summary" -> estimateContainerHeightDp( + element = element, + availableWidthDp = widthDp, + defaultMetrics = bodyTextMetrics, + extraPaddingDp = 8f, + ) + in headingMetrics.keys -> estimateContainerHeightDp( + element = element, + availableWidthDp = widthDp, + defaultMetrics = headingMetrics.getValue(element.tagName()), + extraPaddingDp = 0f, + ) + "li" -> estimateContainerHeightDp( + element = element, + availableWidthDp = (widthDp - 18f).coerceAtLeast(72f), + defaultMetrics = listItemTextMetrics, + extraPaddingDp = 4f, + ) + "div", "p" -> estimateContainerHeightDp( + element = element, + availableWidthDp = widthDp, + defaultMetrics = bodyTextMetrics, + extraPaddingDp = 0f, + ) + else -> estimateContainerHeightDp( + element = element, + availableWidthDp = widthDp, + defaultMetrics = bodyTextMetrics, + extraPaddingDp = 0f, + ) + } +} + +private fun estimateContainerHeightDp( + element: Element, + availableWidthDp: Float, + defaultMetrics: TextBlockMetrics, + extraPaddingDp: Float, +): Float { + val blockChildren = element.children().filter { it.shouldBeMeasuredAsBlock() } + if (blockChildren.isEmpty()) { + return estimateTextHeightDp( + text = element.text().normalizeForMeasurement(), + availableWidthDp = availableWidthDp, + metrics = defaultMetrics, + ) + extraPaddingDp + } + + var totalHeightDp = estimateTextHeightDp( + text = element.inlineTextWithoutBlockChildren().normalizeForMeasurement(), + availableWidthDp = availableWidthDp, + metrics = defaultMetrics, + ) + + for (child in blockChildren) { + totalHeightDp += estimateNodeHeightDp( + element = child, + availableWidthDp = availableWidthDp, + ) + } + + return totalHeightDp + extraPaddingDp +} + +private fun estimateListHeightDp( + element: Element, + availableWidthDp: Float, +): Float { + val listItems = element.children().filter { it.tagName() == "li" } + if (listItems.isEmpty()) { + return 48f + } + + var totalHeightDp = 8f + for (item in listItems) { + totalHeightDp += estimateNodeHeightDp( + element = item, + availableWidthDp = availableWidthDp, + ) + } + return totalHeightDp + 4f +} + +private fun estimateDetailsHeightDp( + element: Element, + availableWidthDp: Float, +): Float { + val summary = element.children().firstOrNull { it.tagName() == "summary" } + var totalHeightDp = if (summary != null) { + estimateNodeHeightDp(summary, availableWidthDp) + } else { + 28f + } + + val contentChildren = element.children().filter { it.tagName() != "summary" } + if (contentChildren.isEmpty()) { + return totalHeightDp + 12f + } + + for (child in contentChildren) { + totalHeightDp += estimateNodeHeightDp(child, availableWidthDp) + } + return totalHeightDp + 12f +} + +private fun estimatePreformattedHeightDp( + text: String, + availableWidthDp: Float, +): Float { + val normalizedLines = text.lines().ifEmpty { listOf("") } + val charsPerLine = max((availableWidthDp / codeBlockTextMetrics.avgCharWidthDp).toInt(), 8) + + val wrappedLines = normalizedLines.sumOf { line -> + val currentLine = line.ifEmpty { " " } + max(1, ceil(currentLine.length / charsPerLine.toDouble()).toInt()) + } + + return wrappedLines * codeBlockTextMetrics.lineHeightDp + codeBlockTextMetrics.marginDp +} + +private fun estimateTableHeightDp( + element: Element, + availableWidthDp: Float, +): Float { + val rows = element.select("tr") + if (rows.isEmpty()) { + return 96f + } + + var totalHeightDp = 12f + for (row in rows.take(12)) { + val cells = row.children().filter { child -> + val tag = child.tagName() + tag == "td" || tag == "th" + } + val columnCount = max(cells.size, 1) + val cellWidthDp = ((availableWidthDp - 16f) / columnCount).coerceAtLeast(56f) + + var rowHeightDp = 28f + for (cell in cells) { + var cellHeightDp = estimateContainerHeightDp( + element = cell, + availableWidthDp = cellWidthDp, + defaultMetrics = tableCellTextMetrics, + extraPaddingDp = 12f, + ) + if (cell.select("img").isNotEmpty()) { + cellHeightDp = max(cellHeightDp, 108f) + } + rowHeightDp = max(rowHeightDp, cellHeightDp) + } + + totalHeightDp += rowHeightDp + } + + if (rows.size > 12) { + totalHeightDp += 24f + } + + return totalHeightDp.coerceIn(96f, 720f) +} + +private fun estimateImageHeightDp( + element: Element, + availableWidthDp: Float, +): Float { + val attrWidth = element.attr("width").toFloatOrNull() + val attrHeight = element.attr("height").toFloatOrNull() + + val imageHeightDp = when { + attrWidth != null && attrWidth > 0f && attrHeight != null && attrHeight > 0f -> { + (availableWidthDp * (attrHeight / attrWidth)) + .coerceIn(96f, 420f) + } + attrWidth != null && attrWidth > 0f -> { + (availableWidthDp * 0.75f).coerceIn(120f, 300f) + } + else -> 180f + } + + return imageHeightDp + 16f +} + +private fun estimateTextHeightDp( + text: String, + availableWidthDp: Float, + metrics: TextBlockMetrics, +): Float { + if (text.isBlank()) { + return metrics.marginDp + } + + val charsPerLine = max((availableWidthDp / metrics.avgCharWidthDp).toInt(), 8) + val paragraphs = text.split('\n') + var lines = 0 + + for (paragraph in paragraphs) { + val currentParagraph = paragraph.trim() + lines += if (currentParagraph.isEmpty()) { + 1 + } else { + max(1, ceil(currentParagraph.length / charsPerLine.toDouble()).toInt()) + } + } + + return lines * metrics.lineHeightDp + metrics.marginDp +} + +private fun Element.shouldBeMeasuredAsBlock(): Boolean { + return tagName() in measurementBlockTags +} + +private fun Element.inlineTextWithoutBlockChildren(): String { + val clone = clone() + clone.children() + .filter { it.shouldBeMeasuredAsBlock() } + .forEach { it.remove() } + return clone.text() +} + +private fun String.normalizeForMeasurement(): String { + return replace('\u00A0', ' ') + .replace(Regex("[\\t\\x0B\\f\\r ]+"), " ") + .replace(Regex("\\n{3,}"), "\n\n") + .trim() +} + +private fun wrapHtml( + sanitizedContent: String, + css: String, +): String { return """ - - - - - - - - ${content.sanitizeHTML()} - - + + + + + + + + $sanitizedContent + """.trimIndent() } private fun colorToCss(color: Color): String { - val argb = color.toArgb() - return String.format("#%06X", 0xFFFFFF and argb) + return String.format("#%06X", 0xFFFFFF and color.toArgb()) } - -@OptIn(ExperimentalCoroutinesApi::class) -fun Flow>?>.flattenTimelineEvents(): Flow = this - .filterNotNull() - .flatMapConcat { middleFlow -> - middleFlow.flatMapConcat { innerFlow -> - innerFlow - } - } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3aa7d2..fa6ae02 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "9.1.0" coil = "3.4.0" +iconsaxCompose = "0.0.5" jsoup = "1.22.1" kotlin = "2.2.21" coreKtx = "1.15.0" @@ -29,6 +30,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" } +iconsax-compose = { module = "io.github.rabehx:iconsax-compose", version.ref = "iconsaxCompose" } 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" }