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..468b88a 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 @@ -9,145 +9,190 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.net.Uri +import android.view.MotionEvent +import android.view.ViewGroup import android.webkit.WebResourceRequest +import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box 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.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.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView 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.user +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 +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.ArrowRight +import io.github.rabehx.iconsax.filled.Send +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.safety.Safelist import ru.risdeveau.pixeldragon.client -import ru.risdeveau.pixeldragon.ui.item.MXCImage +@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.trim() }, + ) + IconButton( + enabled = message.isNotBlank(), + content = { Icon(Iconsax.Filled.ArrowRight, contentDescription = "Send") }, + onClick = { + CoroutineScope(Dispatchers.IO).launch { + client!!.room.sendMessage(RoomId(rid)) { + text(message) + } + } + 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) { + HtmlRenderer( + 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) + ) } } } @@ -163,205 +208,115 @@ private fun String.sanitizeHTML(): String { "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" - ) + .addAttributes("a", "target", "href") + .addAttributes("img", "width", "height", "alt", "title", "src") .addAttributes("ol", "start") .addAttributes("code", "class") .addAttributes("div", "data-mx-maths") val doc = Jsoup.parse(this) doc.select("mx-reply").remove() - - val out = Jsoup.clean(doc.body().toString(), matrixSafelist) - return out + return Jsoup.clean(doc.body().toString(), matrixSafelist) } +@SuppressLint("ClickableViewAccessibility") @Composable fun HtmlRenderer( htmlContent: String, - modifier: Modifier = Modifier + 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.6; + line-height: 1.5; 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)}; - } + 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) { - webView.loadDataWithBaseURL( - null, - wrapHtml(htmlContent, css), - "text/html", - "UTF-8", - null - ) + 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) } DisposableEffect(webView) { - onDispose { - webView.destroy() - } + onDispose { /* webView.destroy() */ } } - AndroidView( - factory = { webView }, - modifier = modifier, - update = { view -> - view.webViewClient = SafeWebViewClient(context) - } - ) -} - -private class SafeWebViewClient( - private val context: Context -) : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView, - request: WebResourceRequest - ): Boolean { - val url = request.url.toString() - try { - // Открываем ссылки во внешнем браузере - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(url)) + 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 ) - return true - } catch (e: Exception) { - // Обработка ошибок открытия ссылки - return false } + + AndroidView( + factory = { webView }, + modifier = Modifier + .fillMaxWidth() + .alpha(if (isLoaded) 1f else 0f) + ) } } private fun wrapHtml(content: String, css: String): String { return """ - - - - - - - - ${content.sanitizeHTML()} - - + + + ${content.sanitizeHTML()} """.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" }