Compare commits

1 Commits

Author SHA1 Message Date
Sweetbread a91bbaf129 wip: show messages 2026-03-20 17:04:26 +03:00
6 changed files with 238 additions and 200 deletions
+6 -10
View File
@@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/*
* Created by sweetbread
* Copyright (c) 2026. All rights reserved.
@@ -7,13 +5,14 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
// alias(libs.plugins.ksp)
}
android {
namespace = "ru.risdeveau.pixeldragon"
compileSdk = 36
compileSdk = 35
defaultConfig {
applicationId = "ru.risdeveau.pixeldragon"
@@ -38,17 +37,14 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -98,5 +94,5 @@ dependencies {
// Others
implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser
implementation(libs.iconsax.compose) // Material icons
}
@@ -40,7 +40,6 @@ 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
@@ -95,9 +94,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}",
@@ -8,7 +8,8 @@ package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import io.github.rabehx.iconsax.Iconsax
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
@@ -27,7 +28,6 @@ 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(
Iconsax.Outline.Warning2,
Icons.Outlined.Warning,
contentDescription = "Error",
modifier = Modifier.align(Alignment.Center)
)
@@ -9,190 +9,145 @@ 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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.font.FontWeight
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.store.eventId
import de.connect2x.trixnity.client.store.unsigned
import de.connect2x.trixnity.client.user
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 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 io.ktor.util.reflect.instanceOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
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 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 events by eventsFlow.collectAsState(initial = emptyList())
val eventFlows by eventsFlow.collectAsState(initial = emptyList())
val listState = rememberLazyListState()
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))
}
}
LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
items(eventFlows, key = { it.hashCode() }) { eventFlow ->
EventItem(eventFlow)
}
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 = ""
}
)
if (eventFlows.isEmpty()) {
item {
Text("Empty room", modifier = Modifier.padding(16.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventItem(event: TimelineEvent) {
val content = event.content?.getOrNull()
Box(Modifier
.fillMaxWidth()
.heightIn(min = 24.dp)) {
when {
content == null -> Text("Not decrypted",
fun EventItem(eventFlow: Flow<TimelineEvent>) {
val event by eventFlow.collectAsState(null)
event?.let { event ->
Box(Modifier.fillMaxWidth()) {
/*when (event.type) {
"m.room.message" -> Column(
Modifier
.fillMaxHeight()
.fillMaxSize()
.then(
if (event.sender != ME!!.userId)
Modifier.padding(end = 16.dp)
else
Modifier.padding(start = 16.dp)
)
.padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.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)
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)
)
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)
}
}
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(),
else -> Text(event.content?.getOrNull().toString(),
Modifier
.fillMaxHeight()
.padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp)
)
)*/
Text(event.content?.getOrNull().toString())
}
}
}
@@ -208,115 +163,205 @@ private fun String.sanitizeHTML(): String {
"blockquote", "details", "summary",
"em", "code", "div", "pre", "span", "img"
)
.addAttributes("a", "target", "href")
.addAttributes("img", "width", "height", "alt", "title", "src")
// .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("ol", "start")
.addAttributes("code", "class")
.addAttributes("div", "data-mx-maths")
val doc = Jsoup.parse(this)
doc.select("mx-reply").remove()
return Jsoup.clean(doc.body().toString(), matrixSafelist)
val out = Jsoup.clean(doc.body().toString(), matrixSafelist)
return out
}
@SuppressLint("ClickableViewAccessibility")
@Composable
fun HtmlRenderer(
htmlContent: String,
modifier: Modifier = Modifier,
plainText: String = ""
modifier: Modifier = Modifier
) {
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;
line-height: 1.6;
color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
margin: 0; padding: 2px 0;
word-wrap: break-word;
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)};
}
}
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)
webView.loadDataWithBaseURL(
null,
wrapHtml(htmlContent, css),
"text/html",
"UTF-8",
null
)
}
DisposableEffect(webView) {
onDispose { /* webView.destroy() */ }
onDispose {
webView.destroy()
}
}
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
)
AndroidView(
factory = { webView },
modifier = modifier,
update = { view ->
view.webViewClient = SafeWebViewClient(context)
}
)
}
AndroidView(
factory = { webView },
modifier = Modifier
.fillMaxWidth()
.alpha(if (isLoaded) 1f else 0f)
)
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))
)
return true
} catch (e: Exception) {
// Обработка ошибок открытия ссылки
return false
}
}
}
private fun wrapHtml(content: String, css: String): String {
return """
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>$css</style></head><body>${content.sanitizeHTML()}</body></html>
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
$css
</style>
</head>
<body>
${content.sanitizeHTML()}
</body>
</html>
""".trimIndent()
}
private fun colorToCss(color: Color): String {
return String.format("#%06X", 0xFFFFFF and color.toArgb())
val argb = color.toArgb()
return String.format("#%06X", 0xFFFFFF and argb)
}
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<Flow<Flow<TimelineEvent>>?>.flattenTimelineEvents(): Flow<TimelineEvent> = this
.filterNotNull()
.flatMapConcat { middleFlow ->
middleFlow.flatMapConcat { innerFlow ->
innerFlow
}
}
+8 -10
View File
@@ -1,19 +1,18 @@
[versions]
agp = "9.1.0"
coil = "3.4.0"
iconsaxCompose = "0.0.5"
jsoup = "1.22.1"
agp = "8.13.0"
coil = "3.1.0"
jsoup = "1.20.1"
kotlin = "2.2.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
kotlinxSerializationJson = "1.7.3"
ktor = "3.4.1"
ktor = "3.1.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0"
composeBom = "2026.03.00"
navigationCompose = "2.9.7"
composeBom = "2025.02.00"
navigationCompose = "2.8.8"
room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0"
@@ -30,7 +29,6 @@ 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" }
+1 -1
View File
@@ -6,6 +6,6 @@
#Thu Feb 20 10:45:47 GMT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists