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 * Created by sweetbread
* Copyright (c) 2026. All rights reserved. * Copyright (c) 2026. All rights reserved.
@@ -7,13 +5,14 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
// alias(libs.plugins.ksp) // alias(libs.plugins.ksp)
} }
android { android {
namespace = "ru.risdeveau.pixeldragon" namespace = "ru.risdeveau.pixeldragon"
compileSdk = 36 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "ru.risdeveau.pixeldragon" applicationId = "ru.risdeveau.pixeldragon"
@@ -38,17 +37,14 @@ android {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
} }
} }
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -98,5 +94,5 @@ dependencies {
// Others // Others
implementation(libs.splitties.base) // Syntax sugar implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser 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 de.connect2x.trixnity.clientserverapi.client.SyncState
import ru.risdeveau.pixeldragon.R import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import ru.risdeveau.pixeldragon.util.getMediaStore import ru.risdeveau.pixeldragon.util.getMediaStore
@@ -95,9 +94,9 @@ class MainActivity : ComponentActivity() {
"room/{rid}", "room/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType }) arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry -> ) { navBackStackEntry ->
Room(Modifier // Room(Modifier
.padding(innerPadding) // .padding(innerPadding)
.fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!) // .fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
} }
composable( composable(
"space/{rid}", "space/{rid}",
@@ -8,7 +8,8 @@ package ru.risdeveau.pixeldragon.ui.item
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -27,7 +28,6 @@ import coil3.request.ImageRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import de.connect2x.trixnity.client.media import de.connect2x.trixnity.client.media
import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress import de.connect2x.trixnity.clientserverapi.model.media.FileTransferProgress
import io.github.rabehx.iconsax.outline.Warning2
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
enum class ImageLoadState { enum class ImageLoadState {
@@ -119,7 +119,7 @@ fun MXCImage(
} }
imageLoadState == ImageLoadState.Error -> { imageLoadState == ImageLoadState.Error -> {
Icon( Icon(
Iconsax.Outline.Warning2, Icons.Outlined.Warning,
contentDescription = "Error", contentDescription = "Error",
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
@@ -9,190 +9,145 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.view.MotionEvent
import android.view.ViewGroup
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import de.connect2x.trixnity.client.room 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.room.toFlowList
import de.connect2x.trixnity.client.store.TimelineEvent import de.connect2x.trixnity.client.store.TimelineEvent
import de.connect2x.trixnity.client.store.eventId import de.connect2x.trixnity.client.user
import de.connect2x.trixnity.client.store.unsigned
import de.connect2x.trixnity.core.model.RoomId import de.connect2x.trixnity.core.model.RoomId
import de.connect2x.trixnity.core.model.events.ClientEvent 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.RoomMessageEventContent
import de.connect2x.trixnity.core.model.events.m.room.TopicEventContent import io.ktor.util.reflect.instanceOf
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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.safety.Safelist import org.jsoup.safety.Safelist
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.ui.item.MXCImage
@OptIn(ExperimentalCoroutinesApi::class)
@Composable @Composable
fun Room(modifier: Modifier = Modifier, rid: String) { fun Room(modifier: Modifier = Modifier, rid: String) {
val roomId = remember(rid) { RoomId(rid) } val roomId = remember(rid) { RoomId(rid) }
val limit = remember { MutableStateFlow(50) } val limit = remember { MutableStateFlow(50) }
val eventsFlow = remember(roomId) { val eventsFlow = remember(roomId) {
client!!.room client!!.room
.getLastTimelineEvents(roomId) .getLastTimelineEvents(roomId)
.toFlowList(limit) .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() val listState = rememberLazyListState()
Column(modifier) { LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
var message by remember { mutableStateOf("") } items(eventFlows, key = { it.hashCode() }) { eventFlow ->
EventItem(eventFlow)
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) {
items(events, key = { it.eventId.full }) { event ->
EventItem(event)
} }
if (events.isEmpty()) { if (eventFlows.isEmpty()) {
item { item {
Text("Empty room", modifier = Modifier.padding(16.dp)) 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EventItem(event: TimelineEvent) { fun EventItem(eventFlow: Flow<TimelineEvent>) {
val content = event.content?.getOrNull() val event by eventFlow.collectAsState(null)
Box(Modifier event?.let { event ->
.fillMaxWidth() Box(Modifier.fillMaxWidth()) {
.heightIn(min = 24.dp)) { /*when (event.type) {
when { "m.room.message" -> Column(
content == null -> Text("Not decrypted", 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(),
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.padding(4.dp) .padding(4.dp)
.background(MaterialTheme.colorScheme.errorContainer) .background(MaterialTheme.colorScheme.errorContainer)
.padding(4.dp) .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)
)
} }
} }
} }
@@ -208,115 +163,205 @@ private fun String.sanitizeHTML(): String {
"blockquote", "details", "summary", "blockquote", "details", "summary",
"em", "code", "div", "pre", "span", "img" "em", "code", "div", "pre", "span", "img"
) )
.addAttributes("a", "target", "href") // .addAttributes("span",
.addAttributes("img", "width", "height", "alt", "title", "src") // "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("ol", "start")
.addAttributes("code", "class") .addAttributes("code", "class")
.addAttributes("div", "data-mx-maths") .addAttributes("div", "data-mx-maths")
val doc = Jsoup.parse(this) val doc = Jsoup.parse(this)
doc.select("mx-reply").remove() 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 @Composable
fun HtmlRenderer( fun HtmlRenderer(
htmlContent: String, htmlContent: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier
plainText: String = ""
) { ) {
val context = LocalContext.current val context = LocalContext.current
var isLoaded by remember(htmlContent) { mutableStateOf(false) }
val webView = remember { WebView(context).apply { val webView = remember { WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
settings.apply { settings.apply {
javaScriptEnabled = false javaScriptEnabled = false
loadWithOverviewMode = true loadWithOverviewMode = true
useWideViewPort = true useWideViewPort = true
layoutAlgorithm = WebSettings.LayoutAlgorithm.TEXT_AUTOSIZING
} }
isVerticalScrollBarEnabled = false isVerticalScrollBarEnabled = false
setBackgroundColor(Color.Transparent.toArgb()) setBackgroundColor(Color.Transparent.toArgb())
setOnTouchListener { _, e -> e.action == MotionEvent.ACTION_MOVE }
} } } }
val css = """ val css = """
body { body {
font-family: -apple-system, sans-serif; font-family: -apple-system, sans-serif;
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.6;
color: ${colorToCss(MaterialTheme.colorScheme.onBackground)}; color: ${colorToCss(MaterialTheme.colorScheme.onBackground)};
margin: 0; padding: 2px 0; margin: 0;
word-wrap: break-word; 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() """.trimIndent()
LaunchedEffect(htmlContent) { LaunchedEffect(htmlContent) {
isLoaded = false webView.loadDataWithBaseURL(
webView.webViewClient = object : WebViewClient() { null,
override fun onPageFinished(view: WebView?, url: String?) { wrapHtml(htmlContent, css),
isLoaded = true "text/html",
view?.requestLayout() "UTF-8",
} null
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) { 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( AndroidView(
factory = { webView }, factory = { webView },
modifier = Modifier modifier = modifier,
.fillMaxWidth() update = { view ->
.alpha(if (isLoaded) 1f else 0f) 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))
)
return true
} catch (e: Exception) {
// Обработка ошибок открытия ссылки
return false
}
} }
} }
private fun wrapHtml(content: String, css: String): String { private fun wrapHtml(content: String, css: String): String {
return """ return """
<!DOCTYPE html><html><head> <!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<style>$css</style></head><body>${content.sanitizeHTML()}</body></html> <style>
$css
</style>
</head>
<body>
${content.sanitizeHTML()}
</body>
</html>
""".trimIndent() """.trimIndent()
} }
private fun colorToCss(color: Color): String { 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] [versions]
agp = "9.1.0" agp = "8.13.0"
coil = "3.4.0" coil = "3.1.0"
iconsaxCompose = "0.0.5" jsoup = "1.20.1"
jsoup = "1.22.1"
kotlin = "2.2.21" kotlin = "2.2.21"
coreKtx = "1.15.0" coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.2.1"
espressoCore = "3.7.0" espressoCore = "3.6.1"
kotlinxSerializationJson = "1.7.3" kotlinxSerializationJson = "1.7.3"
ktor = "3.4.1" ktor = "3.1.0"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0" activityCompose = "1.10.0"
composeBom = "2026.03.00" composeBom = "2025.02.00"
navigationCompose = "2.9.7" navigationCompose = "2.8.8"
room = "2.6.1" room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0" splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.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" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } 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" } 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" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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 #Thu Feb 20 10:45:47 GMT 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists