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()
}