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" }