Compare commits
7 Commits
dev
...
d05c3ce367
| Author | SHA1 | Date | |
|---|---|---|---|
| d05c3ce367 | |||
| 1ab649d6a1 | |||
| b3b2796d25 | |||
| c83b3e455f | |||
| bd5f4dd263 | |||
| d28ae0a48c | |||
| f0e6ba189e |
@@ -1,3 +1,8 @@
|
|||||||
|
/*
|
||||||
|
* Created by sweetbread
|
||||||
|
* Copyright (c) 2026. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -58,6 +63,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.compose.ui.unit)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -5,41 +5,70 @@
|
|||||||
|
|
||||||
package ru.risdeveau.pixeldragon.ui.activity
|
package ru.risdeveau.pixeldragon.ui.activity
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
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.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
|
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import de.connect2x.trixnity.client.CryptoDriverModule
|
import de.connect2x.trixnity.client.CryptoDriverModule
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import de.connect2x.trixnity.client.MatrixClient
|
import de.connect2x.trixnity.client.MatrixClient
|
||||||
import de.connect2x.trixnity.client.create
|
import de.connect2x.trixnity.client.create
|
||||||
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
|
import de.connect2x.trixnity.client.cryptodriver.vodozemac.vodozemac
|
||||||
|
import de.connect2x.trixnity.client.flattenValues
|
||||||
|
import de.connect2x.trixnity.client.room
|
||||||
|
import de.connect2x.trixnity.client.store.type
|
||||||
import de.connect2x.trixnity.clientserverapi.client.SyncState
|
import de.connect2x.trixnity.clientserverapi.client.SyncState
|
||||||
|
import de.connect2x.trixnity.clientserverapi.model.user.avatarUrl
|
||||||
|
import de.connect2x.trixnity.clientserverapi.model.user.displayName
|
||||||
|
import de.connect2x.trixnity.core.model.events.m.room.CreateEventContent
|
||||||
|
import io.github.rabehx.iconsax.Iconsax
|
||||||
|
import io.github.rabehx.iconsax.automirrored.outline.ArrowLeft2
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
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.item.Avatar
|
||||||
import ru.risdeveau.pixeldragon.ui.layout.Room
|
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
|
||||||
@@ -47,11 +76,10 @@ import ru.risdeveau.pixeldragon.util.getMediaStore
|
|||||||
import ru.risdeveau.pixeldragon.util.getRoomStore
|
import ru.risdeveau.pixeldragon.util.getRoomStore
|
||||||
import splitties.activities.start
|
import splitties.activities.start
|
||||||
import splitties.init.appCtx
|
import splitties.init.appCtx
|
||||||
import splitties.resources.str
|
import de.connect2x.trixnity.client.store.Room as TrixnityRoom
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -59,34 +87,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
PixelDragonTheme {
|
PixelDragonTheme {
|
||||||
var isClientReady by remember { mutableStateOf(false) }
|
var isClientReady by remember { mutableStateOf(false) }
|
||||||
val syncState by remember { mutableStateOf(SyncState.STOPPED) }
|
|
||||||
|
|
||||||
if (!isClientReady) {
|
if (!isClientReady || client == null) {
|
||||||
CircularProgressIndicator()
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val syncState by client!!.api.sync.currentSyncState
|
||||||
|
.collectAsState(initial = SyncState.STOPPED)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
CenterAlignedTopAppBar(
|
PixelDragonTopBar(
|
||||||
colors = topAppBarColors(
|
navController = navController,
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
syncState = syncState,
|
||||||
titleContentColor = MaterialTheme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
title = {
|
|
||||||
when (syncState) {
|
|
||||||
SyncState.STARTED -> Text("Syncing...")
|
|
||||||
SyncState.INITIAL_SYNC -> Text("Initial sync...")
|
|
||||||
SyncState.STOPPED,
|
|
||||||
SyncState.RUNNING -> Text(str(R.string.app_name))
|
|
||||||
SyncState.TIMEOUT -> Text("No network connection")
|
|
||||||
SyncState.ERROR -> Text("Error syncing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = "rooms") {
|
NavHost(navController = navController, startDestination = "rooms") {
|
||||||
composable("rooms") {
|
composable("rooms") {
|
||||||
RoomList(Modifier.padding(innerPadding), navController)
|
RoomList(Modifier.padding(innerPadding), navController)
|
||||||
@@ -102,7 +124,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
composable(
|
composable(
|
||||||
"space/{rid}",
|
"space/{rid}",
|
||||||
arguments = listOf(navArgument("rid") { type = NavType.StringType })
|
arguments = listOf(navArgument("rid") { type = NavType.StringType })
|
||||||
) { navBackStackEntry ->
|
) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
text = "Not implemented"
|
text = "Not implemented"
|
||||||
@@ -142,3 +164,156 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("FlowOperatorInvokedInComposition")
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun PixelDragonTopBar(
|
||||||
|
navController: NavHostController,
|
||||||
|
syncState: SyncState,
|
||||||
|
) {
|
||||||
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val route = backStackEntry?.destination?.route
|
||||||
|
val rid = backStackEntry?.arguments?.getString("rid")
|
||||||
|
val isRoomLikeScreen = route == "room/{rid}" || route == "space/{rid}"
|
||||||
|
|
||||||
|
val roomsFlow = remember(client) {
|
||||||
|
client!!.room.getAll().flattenValues().map { it.toList() }
|
||||||
|
}
|
||||||
|
val rooms by roomsFlow.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
|
val currentRoom = remember(isRoomLikeScreen, rid, rooms) {
|
||||||
|
if (isRoomLikeScreen && rid != null) {
|
||||||
|
rooms.firstOrNull { it.roomId.toString() == rid }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRoomLikeScreen) {
|
||||||
|
RoomTopBar(
|
||||||
|
room = currentRoom,
|
||||||
|
fallbackTitle = rid ?: stringResource(R.string.app_name),
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
HomeTopBar(syncState = syncState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun HomeTopBar(syncState: SyncState) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
colors = topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(syncState.toStatusTitle() ?: stringResource(R.string.app_name))
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
val client = client!!
|
||||||
|
|
||||||
|
var userName by remember { mutableStateOf("?") }
|
||||||
|
var userAvatar: String? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(client, syncState) {
|
||||||
|
val profile = client.api.user
|
||||||
|
.getProfile(client.userId)
|
||||||
|
.getOrNull()
|
||||||
|
|
||||||
|
userName = profile?.displayName ?: "?"
|
||||||
|
userAvatar = profile?.avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
Avatar(
|
||||||
|
Modifier.size(32.dp).clip(CircleShape),
|
||||||
|
userAvatar,
|
||||||
|
userName,
|
||||||
|
MaterialTheme.colorScheme.background
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RoomTopBar(
|
||||||
|
room: TrixnityRoom?,
|
||||||
|
fallbackTitle: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
colors = topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface.copy(.5f),
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.AutoMirrored.Outline.ArrowLeft2,
|
||||||
|
"To home",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
if (room != null) {
|
||||||
|
RoomTopBarTitle(room)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = fallbackTitle,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoomTopBarTitle(room: TrixnityRoom) {
|
||||||
|
val title = remember(room) { room.displayName() }
|
||||||
|
val isSpace = room.type == CreateEventContent.RoomType.Space
|
||||||
|
val avatarShape = if (isSpace) RoundedCornerShape(8.dp) else CircleShape
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(avatarShape),
|
||||||
|
room.avatarUrl,
|
||||||
|
title
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TrixnityRoom.displayName(): String {
|
||||||
|
return name?.explicitName
|
||||||
|
?: name?.heroes?.firstNotNullOfOrNull { it.localpart }
|
||||||
|
?: roomId.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SyncState.toStatusTitle(): String? {
|
||||||
|
return when (this) {
|
||||||
|
SyncState.INITIAL_SYNC -> "Initial sync..."
|
||||||
|
SyncState.STARTED -> "Syncing..."
|
||||||
|
SyncState.TIMEOUT -> "No network connection"
|
||||||
|
SyncState.ERROR -> "Error syncing"
|
||||||
|
SyncState.RUNNING,
|
||||||
|
SyncState.STOPPED -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* Created by sweetbread
|
||||||
|
* Copyright (c) 2026. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package ru.risdeveau.pixeldragon.ui.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Avatar(
|
||||||
|
modifier: Modifier,
|
||||||
|
url: String?,
|
||||||
|
fallbackName: String,
|
||||||
|
fallbackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
) {
|
||||||
|
if (url != null) {
|
||||||
|
MXCImage(
|
||||||
|
mxcUrl = url,
|
||||||
|
modifier = modifier,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = fallbackName,
|
||||||
|
showProgress = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AvatarPlaceholder(
|
||||||
|
modifier,
|
||||||
|
fallbackName,
|
||||||
|
fallbackColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AvatarPlaceholder(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
fallbackName: String,
|
||||||
|
fallbackColor: Color,
|
||||||
|
) {
|
||||||
|
val initial = fallbackName
|
||||||
|
.trim()
|
||||||
|
.firstOrNull()
|
||||||
|
?.uppercaseChar()
|
||||||
|
?.toString()
|
||||||
|
?: "?"
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.background(fallbackColor),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = initial,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,21 +24,27 @@ import android.webkit.WebSettings
|
|||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -51,6 +57,7 @@ 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.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -58,16 +65,22 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.focus.onFocusEvent
|
||||||
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.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.core.text.HtmlCompat
|
import androidx.core.text.HtmlCompat
|
||||||
import androidx.core.widget.TextViewCompat
|
import androidx.core.widget.TextViewCompat
|
||||||
import de.connect2x.trixnity.client.room
|
import de.connect2x.trixnity.client.room
|
||||||
@@ -77,6 +90,7 @@ import de.connect2x.trixnity.client.store.TimelineEvent
|
|||||||
import de.connect2x.trixnity.client.store.eventId
|
import de.connect2x.trixnity.client.store.eventId
|
||||||
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.FullyReadEventContent
|
||||||
import de.connect2x.trixnity.core.model.events.m.room.AvatarEventContent
|
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.CanonicalAliasEventContent
|
||||||
import de.connect2x.trixnity.core.model.events.m.room.HistoryVisibilityEventContent
|
import de.connect2x.trixnity.core.model.events.m.room.HistoryVisibilityEventContent
|
||||||
@@ -87,7 +101,6 @@ import de.connect2x.trixnity.core.model.events.m.room.RoomMessageEventContent
|
|||||||
import de.connect2x.trixnity.core.model.events.m.room.TopicEventContent
|
import de.connect2x.trixnity.core.model.events.m.room.TopicEventContent
|
||||||
import io.github.rabehx.iconsax.Iconsax
|
import io.github.rabehx.iconsax.Iconsax
|
||||||
import io.github.rabehx.iconsax.filled.Send2
|
import io.github.rabehx.iconsax.filled.Send2
|
||||||
import io.github.rabehx.iconsax.outline.Send2
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -95,6 +108,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
@@ -111,6 +125,13 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private const val WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:"
|
private const val WEBVIEW_HEIGHT_TITLE_PREFIX = "pd-height:"
|
||||||
private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L
|
private const val MESSAGE_GROUP_WINDOW_MS = 5 * 60 * 1000L
|
||||||
|
private const val TIMELINE_PAGE_SIZE = 50
|
||||||
|
private const val TIMELINE_TOP_PREFETCH_THRESHOLD = 4
|
||||||
|
private const val TIMELINE_BOTTOM_AUTOSCROLL_THRESHOLD = 3
|
||||||
|
private const val MAX_INITIAL_UNREAD_SEARCH_LIMIT = 500
|
||||||
|
private val TIMELINE_AVATAR_SIZE = 32.dp
|
||||||
|
private val MESSAGE_FIELD_TOP_PADDING = 8.dp
|
||||||
|
private val MESSAGE_FIELD_SIZE = 48.dp
|
||||||
|
|
||||||
private val simpleHtmlTags = setOf(
|
private val simpleHtmlTags = setOf(
|
||||||
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2",
|
"a", "b", "blockquote", "br", "code", "del", "div", "em", "h1", "h2",
|
||||||
@@ -170,13 +191,18 @@ private val headingMetrics = mapOf(
|
|||||||
"h6" to TextBlockMetrics(22f, 8f, 10f),
|
"h6" to TextBlockMetrics(22f, 8f, 10f),
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalFoundationApi::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) }
|
var requestedLimit by remember(roomId) { mutableStateOf(TIMELINE_PAGE_SIZE) }
|
||||||
|
val limit = remember(roomId) { MutableStateFlow(TIMELINE_PAGE_SIZE) }
|
||||||
|
|
||||||
val eventsFlow = remember(roomId) {
|
LaunchedEffect(requestedLimit) {
|
||||||
|
limit.value = requestedLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventsFlow = remember(roomId, client!!) {
|
||||||
client!!.room
|
client!!.room
|
||||||
.getLastTimelineEvents(roomId)
|
.getLastTimelineEvents(roomId)
|
||||||
.toFlowList(limit)
|
.toFlowList(limit)
|
||||||
@@ -186,55 +212,210 @@ fun Room(modifier: Modifier = Modifier, rid: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val events by eventsFlow.collectAsState(initial = emptyList())
|
val events by eventsFlow.collectAsState(initial = emptyList())
|
||||||
val currentUserId = remember { client!!.userId.toString() }
|
|
||||||
val timelineItems = remember(events, currentUserId) {
|
val readMarkerFlow = remember(roomId, client!!) {
|
||||||
buildTimelineItems(events = events, currentUserId = currentUserId)
|
client!!.room
|
||||||
|
.getAccountData(roomId, FullyReadEventContent::class)
|
||||||
|
.map { content -> ReadMarkerState.Ready(content?.eventId?.full) as ReadMarkerState }
|
||||||
|
}
|
||||||
|
val readMarkerState by readMarkerFlow.collectAsState(initial = ReadMarkerState.Loading)
|
||||||
|
val fullyReadEventId = (readMarkerState as? ReadMarkerState.Ready)?.eventId
|
||||||
|
|
||||||
|
val currentUserId = remember(client!!) { client!!.userId.toString() }
|
||||||
|
val timelineItems = remember(events, currentUserId, fullyReadEventId) {
|
||||||
|
buildTimelineItems(
|
||||||
|
events = events,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
fullyReadEventId = fullyReadEventId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val latestTimelineKey = remember(timelineItems) {
|
||||||
|
timelineItems.lastOrNull()?.latestKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
var didInitialScroll by remember(roomId) { mutableStateOf(false) }
|
||||||
|
var lastAutoScrollTimelineKey by remember(roomId) { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val isNearBottom by remember(timelineItems, listState) {
|
||||||
|
derivedStateOf {
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
if (totalItems == 0) return@derivedStateOf true
|
||||||
|
|
||||||
|
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
||||||
|
?: return@derivedStateOf false
|
||||||
|
lastVisibleIndex >= totalItems - 1 - TIMELINE_BOTTOM_AUTOSCROLL_THRESHOLD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val shouldLoadOlder by remember(listState, didInitialScroll) {
|
||||||
|
derivedStateOf {
|
||||||
|
didInitialScroll &&
|
||||||
|
listState.layoutInfo.totalItemsCount > 0 &&
|
||||||
|
listState.firstVisibleItemIndex <= TIMELINE_TOP_PREFETCH_THRESHOLD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
var message by remember { mutableStateOf("") }
|
var message by remember { mutableStateOf("") }
|
||||||
|
|
||||||
LazyColumn(Modifier.weight(1f), state = listState, reverseLayout = true) {
|
LaunchedEffect(timelineItems, readMarkerState, events.size, requestedLimit) {
|
||||||
items(timelineItems, key = { it.key }) { item ->
|
if (didInitialScroll || timelineItems.isEmpty()) return@LaunchedEffect
|
||||||
TimelineItem(item)
|
if (readMarkerState is ReadMarkerState.Loading) return@LaunchedEffect
|
||||||
|
|
||||||
|
val readMarkerEventId = (readMarkerState as ReadMarkerState.Ready).eventId
|
||||||
|
val unreadDividerIndex = timelineItems.indexOfFirst { it is UnreadDividerItem }
|
||||||
|
if (unreadDividerIndex >= 0) {
|
||||||
|
listState.scrollToItem(unreadDividerIndex)
|
||||||
|
didInitialScroll = true
|
||||||
|
lastAutoScrollTimelineKey = latestTimelineKey
|
||||||
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineItems.isEmpty()) {
|
val readMarkerIsLoaded = readMarkerEventId != null &&
|
||||||
item {
|
events.any { it.eventId.full == readMarkerEventId }
|
||||||
Text("Empty room", modifier = Modifier.padding(16.dp))
|
val shouldSearchOlderForUnreadMarker = readMarkerEventId != null &&
|
||||||
|
!readMarkerIsLoaded &&
|
||||||
|
events.size >= requestedLimit &&
|
||||||
|
requestedLimit < MAX_INITIAL_UNREAD_SEARCH_LIMIT
|
||||||
|
|
||||||
|
if (shouldSearchOlderForUnreadMarker) {
|
||||||
|
requestedLimit = (requestedLimit + TIMELINE_PAGE_SIZE)
|
||||||
|
.coerceAtMost(MAX_INITIAL_UNREAD_SEARCH_LIMIT)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
listState.scrollToItem(timelineItems.lastIndex)
|
||||||
|
didInitialScroll = true
|
||||||
|
lastAutoScrollTimelineKey = latestTimelineKey
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(shouldLoadOlder, events.size, requestedLimit) {
|
||||||
|
if (!shouldLoadOlder) return@LaunchedEffect
|
||||||
|
if (events.size < requestedLimit) return@LaunchedEffect
|
||||||
|
|
||||||
|
requestedLimit += TIMELINE_PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(latestTimelineKey) {
|
||||||
|
val currentLatestKey = latestTimelineKey ?: return@LaunchedEffect
|
||||||
|
val previousLatestKey = lastAutoScrollTimelineKey
|
||||||
|
|
||||||
|
if (!didInitialScroll) {
|
||||||
|
lastAutoScrollTimelineKey = currentLatestKey
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousLatestKey != null && previousLatestKey != currentLatestKey && isNearBottom) {
|
||||||
|
listState.animateScrollToItem(timelineItems.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAutoScrollTimelineKey = currentLatestKey
|
||||||
|
}
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clipToBounds()
|
||||||
|
) {
|
||||||
|
val listContainerHeightPx = with(density) { maxHeight.roundToPx() }
|
||||||
|
val stickyAvatar by remember(timelineItems, listState, density, listContainerHeightPx) {
|
||||||
|
derivedStateOf {
|
||||||
|
calculateStickyAvatarState(
|
||||||
|
timelineItems = timelineItems,
|
||||||
|
layoutInfo = listState.layoutInfo,
|
||||||
|
avatarSizePx = with(density) { TIMELINE_AVATAR_SIZE.roundToPx() },
|
||||||
|
viewportBottomLimitPx = listContainerHeightPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), state = listState) {
|
||||||
|
for (timelineItem in timelineItems) {
|
||||||
|
when (timelineItem) {
|
||||||
|
is DateDividerItem -> stickyHeader(key = timelineItem.key) {
|
||||||
|
DateDivider(timelineItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> item(key = timelineItem.key) {
|
||||||
|
TimelineItem(
|
||||||
|
item = timelineItem,
|
||||||
|
pinnedAvatarGroupKey = stickyAvatar?.groupKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timelineItems.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("Empty room", modifier = Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stickyAvatar?.let { avatarState ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = with(density) { MESSAGE_FIELD_TOP_PADDING.roundToPx() },
|
||||||
|
y = avatarState.yPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.zIndex(1f),
|
||||||
|
) {
|
||||||
|
AvatarPlaceholder(avatarState.senderLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(4.dp)
|
.clearFocusOnKeyboardDismiss()
|
||||||
|
.padding(MESSAGE_FIELD_TOP_PADDING)
|
||||||
|
.height(MESSAGE_FIELD_SIZE)
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
|
shape = CircleShape,
|
||||||
value = message,
|
value = message,
|
||||||
onValueChange = { message = it },
|
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 {
|
AnimatedVisibility(message.isNotBlank()) {
|
||||||
client!!.room.sendMessage(RoomId(rid)) {
|
IconButton(
|
||||||
text(payload)
|
modifier = Modifier
|
||||||
|
.size(MESSAGE_FIELD_SIZE)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
|
enabled = message.isNotBlank(),
|
||||||
|
content = {
|
||||||
|
Icon(Iconsax.Filled.Send2,
|
||||||
|
contentDescription = "Send",
|
||||||
|
tint = MaterialTheme.colorScheme.inversePrimary
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val payload = message.trim()
|
||||||
|
if (payload.isBlank()) return@IconButton
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
client!!.room.sendMessage(RoomId(rid)) {
|
||||||
|
text(payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
message = ""
|
||||||
}
|
}
|
||||||
message = ""
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed interface ReadMarkerState {
|
||||||
|
data object Loading : ReadMarkerState
|
||||||
|
data class Ready(val eventId: String?) : ReadMarkerState
|
||||||
|
}
|
||||||
|
|
||||||
private sealed interface TimelineUiItem {
|
private sealed interface TimelineUiItem {
|
||||||
val key: String
|
val key: String
|
||||||
}
|
}
|
||||||
@@ -244,18 +425,33 @@ private data class DateDividerItem(
|
|||||||
val label: String,
|
val label: String,
|
||||||
) : TimelineUiItem
|
) : TimelineUiItem
|
||||||
|
|
||||||
private data class MessageTimelineItem(
|
private data class UnreadDividerItem(
|
||||||
override val key: String,
|
override val key: String,
|
||||||
|
) : TimelineUiItem
|
||||||
|
|
||||||
|
private data class MessageGroupItem(
|
||||||
|
override val key: String,
|
||||||
|
val senderId: String?,
|
||||||
|
val senderLabel: String,
|
||||||
|
val isOwn: Boolean,
|
||||||
|
val messages: List<MessageTimelineItem>,
|
||||||
|
) : TimelineUiItem
|
||||||
|
|
||||||
|
private data class MessageTimelineItem(
|
||||||
|
val key: String,
|
||||||
val content: RoomMessageEventContent,
|
val content: RoomMessageEventContent,
|
||||||
val senderId: String?,
|
val senderId: String?,
|
||||||
val senderLabel: String,
|
val senderLabel: String,
|
||||||
val timeLabel: String?,
|
val timeLabel: String?,
|
||||||
val timestampMs: Long?,
|
val timestampMs: Long?,
|
||||||
val isOwn: Boolean,
|
val isOwn: Boolean,
|
||||||
val showSender: Boolean,
|
)
|
||||||
val showAvatar: Boolean,
|
|
||||||
val showTimestamp: Boolean,
|
private data class StickyAvatarState(
|
||||||
) : TimelineUiItem
|
val groupKey: String,
|
||||||
|
val senderLabel: String,
|
||||||
|
val yPx: Int,
|
||||||
|
)
|
||||||
|
|
||||||
private data class SystemTimelineItem(
|
private data class SystemTimelineItem(
|
||||||
override val key: String,
|
override val key: String,
|
||||||
@@ -265,6 +461,7 @@ private data class SystemTimelineItem(
|
|||||||
private fun buildTimelineItems(
|
private fun buildTimelineItems(
|
||||||
events: List<TimelineEvent>,
|
events: List<TimelineEvent>,
|
||||||
currentUserId: String,
|
currentUserId: String,
|
||||||
|
fullyReadEventId: String?,
|
||||||
): List<TimelineUiItem> {
|
): List<TimelineUiItem> {
|
||||||
if (events.isEmpty()) return emptyList()
|
if (events.isEmpty()) return emptyList()
|
||||||
|
|
||||||
@@ -272,7 +469,9 @@ private fun buildTimelineItems(
|
|||||||
val items = mutableListOf<TimelineUiItem>()
|
val items = mutableListOf<TimelineUiItem>()
|
||||||
|
|
||||||
var previousDayStartMs: Long? = null
|
var previousDayStartMs: Long? = null
|
||||||
var lastMessageIndex: Int? = null
|
var lastMessageGroupIndex: Int? = null
|
||||||
|
var insertUnreadDividerBeforeNextMessage = false
|
||||||
|
var unreadDividerInserted = false
|
||||||
|
|
||||||
for (event in chronologicalEvents) {
|
for (event in chronologicalEvents) {
|
||||||
val content = event.content?.getOrNull()
|
val content = event.content?.getOrNull()
|
||||||
@@ -285,7 +484,18 @@ private fun buildTimelineItems(
|
|||||||
label = formatDateDividerLabel(dayStartMs),
|
label = formatDateDividerLabel(dayStartMs),
|
||||||
)
|
)
|
||||||
previousDayStartMs = dayStartMs
|
previousDayStartMs = dayStartMs
|
||||||
lastMessageIndex = null
|
lastMessageGroupIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
content is RoomMessageEventContent &&
|
||||||
|
insertUnreadDividerBeforeNextMessage &&
|
||||||
|
!unreadDividerInserted
|
||||||
|
) {
|
||||||
|
items += UnreadDividerItem(key = "unread:$fullyReadEventId")
|
||||||
|
unreadDividerInserted = true
|
||||||
|
insertUnreadDividerBeforeNextMessage = false
|
||||||
|
lastMessageGroupIndex = null
|
||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -294,7 +504,7 @@ private fun buildTimelineItems(
|
|||||||
key = "event:${event.eventId.full}",
|
key = "event:${event.eventId.full}",
|
||||||
text = "Not decrypted",
|
text = "Not decrypted",
|
||||||
)
|
)
|
||||||
lastMessageIndex = null
|
lastMessageGroupIndex = null
|
||||||
}
|
}
|
||||||
|
|
||||||
content is RoomMessageEventContent -> {
|
content is RoomMessageEventContent -> {
|
||||||
@@ -303,22 +513,7 @@ private fun buildTimelineItems(
|
|||||||
val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() }
|
val senderLabel = event.extractSenderDisplayName().takeUnless { it.isNullOrBlank() }
|
||||||
?: senderId.toSenderLabel()
|
?: senderId.toSenderLabel()
|
||||||
val timeLabel = timestampMs?.let(::formatTimeLabel)
|
val timeLabel = timestampMs?.let(::formatTimeLabel)
|
||||||
|
val message = MessageTimelineItem(
|
||||||
val previousMessage = lastMessageIndex
|
|
||||||
?.let { items.getOrNull(it) as? MessageTimelineItem }
|
|
||||||
val groupedWithPrevious = previousMessage != null &&
|
|
||||||
previousMessage.senderId != null &&
|
|
||||||
previousMessage.senderId == senderId &&
|
|
||||||
previousMessage.isOwn == isOwn &&
|
|
||||||
previousMessage.timestampMs != null &&
|
|
||||||
timestampMs != null &&
|
|
||||||
timestampMs - previousMessage.timestampMs <= MESSAGE_GROUP_WINDOW_MS
|
|
||||||
|
|
||||||
if (groupedWithPrevious && previousMessage != null && lastMessageIndex != null) {
|
|
||||||
items[lastMessageIndex] = previousMessage.copy(showTimestamp = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
items += MessageTimelineItem(
|
|
||||||
key = "event:${event.eventId.full}",
|
key = "event:${event.eventId.full}",
|
||||||
content = content,
|
content = content,
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
@@ -326,11 +521,33 @@ private fun buildTimelineItems(
|
|||||||
timeLabel = timeLabel,
|
timeLabel = timeLabel,
|
||||||
timestampMs = timestampMs,
|
timestampMs = timestampMs,
|
||||||
isOwn = isOwn,
|
isOwn = isOwn,
|
||||||
showSender = !isOwn && !groupedWithPrevious,
|
|
||||||
showAvatar = !isOwn && !groupedWithPrevious,
|
|
||||||
showTimestamp = true,
|
|
||||||
)
|
)
|
||||||
lastMessageIndex = items.lastIndex
|
|
||||||
|
val previousGroup = lastMessageGroupIndex
|
||||||
|
?.let { items.getOrNull(it) as? MessageGroupItem }
|
||||||
|
val previousMessage = previousGroup?.messages?.lastOrNull()
|
||||||
|
val groupedWithPrevious = previousGroup != null &&
|
||||||
|
previousGroup.senderId != null &&
|
||||||
|
previousGroup.senderId == senderId &&
|
||||||
|
previousGroup.isOwn == isOwn &&
|
||||||
|
previousMessage?.timestampMs != null &&
|
||||||
|
timestampMs != null &&
|
||||||
|
timestampMs - previousMessage.timestampMs <= MESSAGE_GROUP_WINDOW_MS
|
||||||
|
|
||||||
|
if (groupedWithPrevious) {
|
||||||
|
items[lastMessageGroupIndex] = previousGroup.copy(
|
||||||
|
messages = previousGroup.messages + message,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
items += MessageGroupItem(
|
||||||
|
key = "group:${event.eventId.full}",
|
||||||
|
senderId = senderId,
|
||||||
|
senderLabel = senderLabel,
|
||||||
|
isOwn = isOwn,
|
||||||
|
messages = listOf(message),
|
||||||
|
)
|
||||||
|
lastMessageGroupIndex = items.lastIndex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
event.event is ClientEvent.RoomEvent.StateEvent -> {
|
event.event is ClientEvent.RoomEvent.StateEvent -> {
|
||||||
@@ -338,7 +555,7 @@ private fun buildTimelineItems(
|
|||||||
key = "event:${event.eventId.full}",
|
key = "event:${event.eventId.full}",
|
||||||
text = stateEventLabel(content),
|
text = stateEventLabel(content),
|
||||||
)
|
)
|
||||||
lastMessageIndex = null
|
lastMessageGroupIndex = null
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -346,34 +563,86 @@ private fun buildTimelineItems(
|
|||||||
key = "event:${event.eventId.full}",
|
key = "event:${event.eventId.full}",
|
||||||
text = content.toString(),
|
text = content.toString(),
|
||||||
)
|
)
|
||||||
lastMessageIndex = null
|
lastMessageGroupIndex = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!unreadDividerInserted && fullyReadEventId != null && event.eventId.full == fullyReadEventId) {
|
||||||
|
insertUnreadDividerBeforeNextMessage = true
|
||||||
|
lastMessageGroupIndex = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.asReversed()
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimelineItem(item: TimelineUiItem) {
|
private fun TimelineItem(
|
||||||
|
item: TimelineUiItem,
|
||||||
|
pinnedAvatarGroupKey: String?,
|
||||||
|
) {
|
||||||
when (item) {
|
when (item) {
|
||||||
is DateDividerItem -> DateDivider(item)
|
is DateDividerItem -> DateDivider(item)
|
||||||
is MessageTimelineItem -> MessageRow(item)
|
is UnreadDividerItem -> UnreadDivider()
|
||||||
|
is MessageGroupItem -> MessageGroupRow(
|
||||||
|
group = item,
|
||||||
|
hideInlineAvatar = pinnedAvatarGroupKey == item.key,
|
||||||
|
)
|
||||||
is SystemTimelineItem -> SystemEventRow(item)
|
is SystemTimelineItem -> SystemEventRow(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DateDivider(item: DateDividerItem) {
|
private fun DateDivider(item: DateDividerItem) {
|
||||||
Text(
|
Box(
|
||||||
text = item.label,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
textAlign = TextAlign.Center,
|
contentAlignment = Alignment.Center,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text(
|
||||||
)
|
text = item.label,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f),
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnreadDivider() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(1.dp)
|
||||||
|
.weight(1f)
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Unread messages",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(1.dp)
|
||||||
|
.weight(1f)
|
||||||
|
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -390,58 +659,39 @@ private fun SystemEventRow(item: SystemTimelineItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageRow(item: MessageTimelineItem) {
|
private fun MessageGroupRow(
|
||||||
val bubbleColor = MaterialTheme.colorScheme.surfaceVariant
|
group: MessageGroupItem,
|
||||||
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
hideInlineAvatar: Boolean,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
contentAlignment = if (item.isOwn) Alignment.CenterEnd else Alignment.CenterStart,
|
contentAlignment = if (group.isOwn) Alignment.CenterEnd else Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = if (item.isOwn) Arrangement.End else Arrangement.Start,
|
horizontalArrangement = if (group.isOwn) Arrangement.End else Arrangement.Start,
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
if (!item.isOwn) {
|
if (!group.isOwn) {
|
||||||
if (item.showAvatar) {
|
if (hideInlineAvatar) {
|
||||||
AvatarPlaceholder(item.senderLabel)
|
Spacer(Modifier.size(TIMELINE_AVATAR_SIZE))
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.width(32.dp))
|
AvatarPlaceholder(group.senderLabel)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.widthIn(max = 320.dp),
|
||||||
.widthIn(max = 320.dp)
|
horizontalAlignment = if (group.isOwn) Alignment.End else Alignment.Start,
|
||||||
.background(bubbleColor, RoundedCornerShape(18.dp))
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
||||||
) {
|
) {
|
||||||
if (item.showSender) {
|
for ((index, message) in group.messages.withIndex()) {
|
||||||
Text(
|
MessageBubble(
|
||||||
text = item.senderLabel,
|
item = message,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
showSender = !group.isOwn && index == 0,
|
||||||
fontWeight = FontWeight.SemiBold,
|
showTimestamp = index == group.messages.lastIndex,
|
||||||
color = bubbleTextColor,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MessageBody(
|
|
||||||
content = item.content,
|
|
||||||
textColor = bubbleTextColor,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (item.showTimestamp && item.timeLabel != null) {
|
|
||||||
Text(
|
|
||||||
text = item.timeLabel,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.End)
|
|
||||||
.padding(top = 4.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = bubbleTextColor.copy(alpha = 0.75f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,6 +699,51 @@ private fun MessageRow(item: MessageTimelineItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageBubble(
|
||||||
|
item: MessageTimelineItem,
|
||||||
|
showSender: Boolean,
|
||||||
|
showTimestamp: Boolean,
|
||||||
|
) {
|
||||||
|
val bubbleColor = if (item.senderId != client!!.userId.full)
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
val bubbleTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(bubbleColor, RoundedCornerShape(18.dp))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
if (showSender) {
|
||||||
|
Text(
|
||||||
|
text = item.senderLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = bubbleTextColor,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageBody(
|
||||||
|
content = item.content,
|
||||||
|
textColor = bubbleTextColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showTimestamp && item.timeLabel != null) {
|
||||||
|
Text(
|
||||||
|
text = item.timeLabel,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = bubbleTextColor.copy(alpha = 0.75f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AvatarPlaceholder(senderLabel: String) {
|
private fun AvatarPlaceholder(senderLabel: String) {
|
||||||
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
val initial = senderLabel.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
@@ -490,6 +785,49 @@ private fun MessageBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun TimelineUiItem.latestKey(): String {
|
||||||
|
return when (this) {
|
||||||
|
is MessageGroupItem -> messages.lastOrNull()?.key ?: key
|
||||||
|
else -> key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateStickyAvatarState(
|
||||||
|
timelineItems: List<TimelineUiItem>,
|
||||||
|
layoutInfo: androidx.compose.foundation.lazy.LazyListLayoutInfo,
|
||||||
|
avatarSizePx: Int,
|
||||||
|
viewportBottomLimitPx: Int,
|
||||||
|
): StickyAvatarState? {
|
||||||
|
val viewportBottom = if (viewportBottomLimitPx > 0) {
|
||||||
|
layoutInfo.viewportEndOffset.coerceAtMost(viewportBottomLimitPx)
|
||||||
|
} else {
|
||||||
|
layoutInfo.viewportEndOffset
|
||||||
|
}
|
||||||
|
val viewportTop = layoutInfo.viewportStartOffset
|
||||||
|
val pinnedAvatarTop = viewportBottom - avatarSizePx
|
||||||
|
|
||||||
|
return layoutInfo.visibleItemsInfo
|
||||||
|
.mapNotNull { visibleItem ->
|
||||||
|
val group = timelineItems.getOrNull(visibleItem.index) as? MessageGroupItem
|
||||||
|
?: return@mapNotNull null
|
||||||
|
if (group.isOwn) return@mapNotNull null
|
||||||
|
|
||||||
|
val groupTop = visibleItem.offset
|
||||||
|
val groupBottom = visibleItem.offset + visibleItem.size
|
||||||
|
val normalAvatarTop = groupBottom - avatarSizePx
|
||||||
|
val isVisible = groupBottom > viewportTop && groupTop < viewportBottom
|
||||||
|
val shouldPin = isVisible && normalAvatarTop > pinnedAvatarTop
|
||||||
|
if (!shouldPin) return@mapNotNull null
|
||||||
|
|
||||||
|
StickyAvatarState(
|
||||||
|
groupKey = group.key,
|
||||||
|
senderLabel = group.senderLabel,
|
||||||
|
yPx = pinnedAvatarTop.coerceAtLeast(groupTop),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.lastOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
private fun stateEventLabel(content: Any): String {
|
private fun stateEventLabel(content: Any): String {
|
||||||
return when (content) {
|
return when (content) {
|
||||||
is AvatarEventContent -> "Avatar changed"
|
is AvatarEventContent -> "Avatar changed"
|
||||||
@@ -943,8 +1281,8 @@ private fun ComplexHtmlRenderer(
|
|||||||
val resolvedHeightPx = rawHeightPx.coerceAtLeast(1)
|
val resolvedHeightPx = rawHeightPx.coerceAtLeast(1)
|
||||||
val currentHeightPx = measuredHeightPx ?: estimatedHeightPx
|
val currentHeightPx = measuredHeightPx ?: estimatedHeightPx
|
||||||
val shouldUpdate = measuredHeightPx == null ||
|
val shouldUpdate = measuredHeightPx == null ||
|
||||||
resolvedHeightPx > currentHeightPx ||
|
resolvedHeightPx > currentHeightPx ||
|
||||||
abs(resolvedHeightPx - currentHeightPx) > updateThresholdPx
|
abs(resolvedHeightPx - currentHeightPx) > updateThresholdPx
|
||||||
|
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
complexHtmlHeightCache[cacheKey] = resolvedHeightPx
|
complexHtmlHeightCache[cacheKey] = resolvedHeightPx
|
||||||
@@ -1000,7 +1338,7 @@ private fun ComplexHtmlRenderer(
|
|||||||
val currentView = view ?: return
|
val currentView = view ?: return
|
||||||
currentView.post {
|
currentView.post {
|
||||||
val fallbackHeightPx = (currentView.contentHeight *
|
val fallbackHeightPx = (currentView.contentHeight *
|
||||||
currentView.resources.displayMetrics.density).roundToInt()
|
currentView.resources.displayMetrics.density).roundToInt()
|
||||||
if (fallbackHeightPx > 0) {
|
if (fallbackHeightPx > 0) {
|
||||||
onHeightMeasured(fallbackHeightPx)
|
onHeightMeasured(fallbackHeightPx)
|
||||||
}
|
}
|
||||||
@@ -1410,3 +1748,28 @@ private fun wrapHtml(
|
|||||||
private fun colorToCss(color: Color): String {
|
private fun colorToCss(color: Color): String {
|
||||||
return String.format("#%06X", 0xFFFFFF and color.toArgb())
|
return String.format("#%06X", 0xFFFFFF and color.toArgb())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
|
||||||
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
var keyboardAppearedSinceLastFocused by remember { mutableStateOf(false) }
|
||||||
|
if (isFocused) {
|
||||||
|
val imeIsVisible = WindowInsets.isImeVisible
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
LaunchedEffect(imeIsVisible) {
|
||||||
|
if (imeIsVisible) {
|
||||||
|
keyboardAppearedSinceLastFocused = true
|
||||||
|
} else if (keyboardAppearedSinceLastFocused) {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onFocusEvent {
|
||||||
|
if (isFocused != it.isFocused) {
|
||||||
|
isFocused = it.isFocused
|
||||||
|
if (isFocused) {
|
||||||
|
keyboardAppearedSinceLastFocused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ navigationCompose = "2.9.7"
|
|||||||
room = "2.6.1"
|
room = "2.6.1"
|
||||||
splittiesFunPackAndroidBase = "3.0.0"
|
splittiesFunPackAndroidBase = "3.0.0"
|
||||||
trixnityClient = "5.2.0"
|
trixnityClient = "5.2.0"
|
||||||
|
uiUnit = "1.10.6"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -53,6 +54,7 @@ trixnity-client = { module = "de.connect2x.trixnity:trixnity-client", version.re
|
|||||||
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
|
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
|
||||||
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
|
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
|
||||||
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
|
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
|
||||||
|
androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user