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/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Room.kt index 468b88a..5574f36 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,27 +1,37 @@ /* * 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 androidx.compose.animation.animateContentSize +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.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 @@ -33,7 +43,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -41,22 +50,25 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +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.MessageBuilder 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.store.eventId -import de.connect2x.trixnity.client.store.unsigned 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 @@ -69,7 +81,6 @@ import de.connect2x.trixnity.core.model.events.m.room.RoomMessageEventContent import de.connect2x.trixnity.core.model.events.m.room.TopicEventContent import io.github.rabehx.iconsax.Iconsax import io.github.rabehx.iconsax.filled.ArrowRight -import io.github.rabehx.iconsax.filled.Send import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -79,8 +90,73 @@ 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 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 @@ -116,21 +192,24 @@ fun Room(modifier: Modifier = Modifier, rid: String) { } } - Row (Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth()) { OutlinedTextField( modifier = Modifier .padding(4.dp) .weight(1f), value = message, - onValueChange = { message = it.trim() }, + onValueChange = { message = it }, ) IconButton( enabled = message.isNotBlank(), content = { Icon(Iconsax.Filled.ArrowRight, contentDescription = "Send") }, onClick = { + val payload = message.trim() + if (payload.isBlank()) return@IconButton + CoroutineScope(Dispatchers.IO).launch { client!!.room.sendMessage(RoomId(rid)) { - text(message) + text(payload) } } message = "" @@ -144,11 +223,14 @@ fun Room(modifier: Modifier = Modifier, rid: String) { @Composable fun EventItem(event: TimelineEvent) { val content = event.content?.getOrNull() - Box(Modifier - .fillMaxWidth() - .heightIn(min = 24.dp)) { + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 24.dp) + ) { when { - content == null -> Text("Not decrypted", + content == null -> Text( + "Not decrypted", Modifier .fillMaxHeight() .padding(4.dp) @@ -159,9 +241,9 @@ fun EventItem(event: TimelineEvent) { content is RoomMessageEventContent -> { val formatted = content.formattedBody if (formatted != null) { - HtmlRenderer( + FormattedMessageContent( htmlContent = formatted, - plainText = content.body + plainText = content.body, ) } else { Text( @@ -186,7 +268,8 @@ fun EventItem(event: TimelineEvent) { textAlign = TextAlign.Center ) - else -> Text(content.toString(), + else -> Text( + content.toString(), Modifier .fillMaxHeight() .padding(4.dp) @@ -197,123 +280,795 @@ fun EventItem(event: TimelineEvent) { } } +@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" + "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() + return Jsoup.clean(doc.body().toString(), matrixSafelist) } - -@SuppressLint("ClickableViewAccessibility") @Composable -fun HtmlRenderer( - htmlContent: String, +private fun SimpleHtmlRenderer( + sanitizedHtml: String, + modifier: Modifier = Modifier, +) { + 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( + 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 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, - plainText: String = "" ) { val context = LocalContext.current - var isLoaded by remember(htmlContent) { mutableStateOf(false) } - - val webView = remember { WebView(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - settings.apply { - javaScriptEnabled = false - loadWithOverviewMode = true - useWideViewPort = true - layoutAlgorithm = WebSettings.LayoutAlgorithm.TEXT_AUTOSIZING - } - isVerticalScrollBarEnabled = false - setBackgroundColor(Color.Transparent.toArgb()) - setOnTouchListener { _, e -> e.action == MotionEvent.ACTION_MOVE } - } } - - val css = """ - body { - font-family: -apple-system, sans-serif; - font-size: 16px; - line-height: 1.5; - color: ${colorToCss(MaterialTheme.colorScheme.onBackground)}; - margin: 0; padding: 2px 0; - word-wrap: break-word; - } - a { color: ${colorToCss(MaterialTheme.colorScheme.primary)}; 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 ${colorToCss(MaterialTheme.colorScheme.outline)}; padding: 8px; text-align: left; } - blockquote { border-left: 4px solid ${colorToCss(MaterialTheme.colorScheme.primary)}; padding-left: 12px; margin: 8px 0; } - pre, code { background-color: ${colorToCss(MaterialTheme.colorScheme.surfaceVariant)}; padding: 2px 4px; border-radius: 4px; } - """.trimIndent() - - LaunchedEffect(htmlContent) { - isLoaded = false - webView.webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - isLoaded = true - view?.requestLayout() - } - override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - return try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(request.url.toString()))) - true - } catch (e: Exception) { false } - } - } - webView.loadDataWithBaseURL(null, wrapHtml(htmlContent, css), "text/html", "UTF-8", null) + 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) } - DisposableEffect(webView) { - onDispose { /* webView.destroy() */ } - } + 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" + } - Box( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 24.dp) - .animateContentSize() - ) { - if (!isLoaded) { - Text( - text = plainText.ifBlank { " " }, - modifier = Modifier - .padding(vertical = 2.dp) - .alpha(0.6f), - style = MaterialTheme.typography.bodyMedium + 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, ) } - AndroidView( - factory = { webView }, + DisposableEffect(webView) { + onDispose { + webView.stopLoading() + webView.destroy() + } + } + + Box( modifier = Modifier .fillMaxWidth() - .alpha(if (isLoaded) 1f else 0f) + .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() + } + } + ) + } + } +} + +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 wrapHtml(content: String, css: String): String { +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() }