feat: Messages

This commit is contained in:
Sweetbread 2025-03-03 23:22:48 +03:00
parent bd87ca2729
commit 23780489f6
7 changed files with 357 additions and 157 deletions

View File

@ -1,7 +1,7 @@
/* /*
* Created by sweetbread on 22.02.2025, 15:45 * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2025. All rights reserved.
* Last modified 22.02.2025, 14:56 * Last modified 03.03.2025, 16:46
*/ */
plugins { plugins {
@ -78,6 +78,13 @@ dependencies {
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
// Navigation Compose
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.ui)
implementation(libs.androidx.navigation.dynamic.features.fragment)
androidTestImplementation(libs.androidx.navigation.testing)
// Others // Others
implementation(libs.splitties.base) // Syntax sugar implementation(libs.splitties.base) // Syntax sugar
} }

View File

@ -0,0 +1,56 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 03.03.2025, 20:21
*/
package ru.risdeveau.pixeldragon.api
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import org.json.JSONObject
import ru.risdeveau.pixeldragon.baseUrl
import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.token
class Event (
val id: String,
val rid: String,
val sender: String,
val type: String,
val content: JSONObject
) {
constructor(json: JSONObject) : this(
json.getString("event_id"),
json.getString("room_id"),
json.getString("sender"),
json.getString("type"),
json.getJSONObject("content")
)
}
data class EventsAround (
val base: Event,
val before: List<Event>,
val after: List<Event>
)
suspend fun getEventsAround(room: String, event: String): EventsAround {
val r = client.get("$baseUrl/rooms/$room/context/$event") {
bearerAuth(token)
parameter("limit", "50")
}
val json = JSONObject(r.bodyAsText())
return EventsAround(
Event(json.getJSONObject("event")),
if (json.has("events_before")) json.getJSONArray("events_before").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}.reversed()
} else listOf(),
if (json.has("events_after")) json.getJSONArray("events_after").let {
List<Event>(it.length()) { i -> Event(it.getJSONObject(i))}
} else listOf()
)
}

View File

@ -1,7 +1,7 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2025. All rights reserved.
* Last modified 22.02.2025, 19:52 * Last modified 03.03.2025, 18:28
*/ */
package ru.risdeveau.pixeldragon.api package ru.risdeveau.pixeldragon.api
@ -55,3 +55,9 @@ private suspend fun getState(rid: String, state: String, key: String): String? {
if (!json.has(key)) return null if (!json.has(key)) return null
return json.getString(key) return json.getString(key)
} }
suspend fun getAccountData(user: String, room: String, state: String): JSONObject? {
val r = client.get("$baseUrl/user/$user/rooms/$room/account_data/$state") { bearerAuth(token) }
if (r.status != HttpStatusCode.OK) return null
return JSONObject(r.bodyAsText())
}

View File

@ -1,7 +1,7 @@
/* /*
* Created by sweetbread * Created by sweetbread
* Copyright (c) 2025. All rights reserved. * Copyright (c) 2025. All rights reserved.
* Last modified 03.03.2025, 15:53 * Last modified 03.03.2025, 20:22
*/ */
package ru.risdeveau.pixeldragon.ui.activity package ru.risdeveau.pixeldragon.ui.activity
@ -10,52 +10,23 @@ import android.os.Bundle
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.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
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.TopAppBar import androidx.compose.material3.TopAppBar
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.navigation.NavType
import androidx.compose.ui.layout.ContentScale import androidx.navigation.compose.NavHost
import androidx.compose.ui.unit.dp import androidx.navigation.compose.composable
import coil3.compose.SubcomposeAsyncImage import androidx.navigation.compose.rememberNavController
import coil3.network.NetworkHeaders import androidx.navigation.navArgument
import coil3.network.httpHeaders import ru.risdeveau.pixeldragon.ui.layout.Room
import coil3.request.ImageRequest import ru.risdeveau.pixeldragon.ui.layout.RoomList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.api.getRoom
import ru.risdeveau.pixeldragon.api.getRooms
import ru.risdeveau.pixeldragon.api.mxcToUrl
import ru.risdeveau.pixeldragon.db.Room
import ru.risdeveau.pixeldragon.token
import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme import ru.risdeveau.pixeldragon.ui.theme.PixelDragonTheme
import splitties.init.appCtx
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -79,123 +50,24 @@ class MainActivity : ComponentActivity() {
) )
}, },
) { innerPadding -> ) { innerPadding ->
RoomList(Modifier.padding(innerPadding)) val navController = rememberNavController()
}
}
}
}
}
@Composable NavHost(navController = navController, startDestination = "rooms") {
fun RoomList(modifier: Modifier = Modifier) { composable("rooms") { RoomList(Modifier.padding(innerPadding), navController) }
var list by remember { mutableStateOf(listOf<String>()) } composable(
val coroutineScope = rememberCoroutineScope() "room/{rid}",
val listState = rememberLazyListState() arguments = listOf(navArgument("rid") { type = NavType.StringType })
) { navBackStackEntry ->
// if (itemState.scrollToTop) { Room(Modifier.padding(innerPadding).fillMaxSize(), navBackStackEntry.arguments!!.getString("rid")!!)
// LaunchedEffect(coroutineScope) {
// Log.e("TAG", "TopCoinsScreen: scrollToTop" )
// listState.scrollToItem(0)
// }
// }
LaunchedEffect(true) {
coroutineScope.launch {
withContext(Dispatchers.IO) {
list = getRooms()
} }
} composable(
} "space/{rid}",
arguments = listOf(navArgument("rid") { type = NavType.StringType })
LazyColumn(modifier = modifier, state = listState) { ) { navBackStackEntry ->
items(list) { rid -> Text(modifier = Modifier.padding(innerPadding), text = "Not implemented") }
RoomItem(rid = rid)
}
item {
if (list.isEmpty()) {
Text("You have no rooms")
} }
} }
} }
} }
@Composable
fun RoomItem(modifier: Modifier = Modifier, rid: String) {
var room by remember { mutableStateOf<Room?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(true) {
scope.launch {
withContext(Dispatchers.IO) {
room = getRoom(rid)
} }
} }
}
if (room != null) {
Row(
modifier
.padding(8.dp)
.height((52+8*2).dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp))
.padding(8.dp)
) {
SubcomposeAsyncImage(
modifier = Modifier
.padding(end = 4.dp)
.height(52.dp)
.width(52.dp)
.let {
if (room!!.type == "m.space")
it.clip(RoundedCornerShape(12.dp))
else
it.clip(CircleShape)
},
model = ImageRequest.Builder(appCtx)
.data(mxcToUrl(room!!.avatarUrl ?: ""))
.httpHeaders(
NetworkHeaders.Builder()
.set("Authorization", "Bearer $token")
.set("Cache-Control", "max-age=86400")
.build()
)
.build(),
contentDescription = room!!.roomId,
contentScale = ContentScale.Crop,
loading = {
CircularProgressIndicator()
}
)
Column {
Text(
room!!.name ?: "Unnamed",
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.titleLarge.fontSize
)
}
}
} else {
Row(
modifier
.padding(8.dp)
.height(80.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp))
.padding(8.dp)
) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
}
//@Preview(showBackground = true)
//@Composable
//fun GreetingPreview() {
// PixelDragonTheme {
// RoomItem()
// }
//}

View File

@ -0,0 +1,90 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 03.03.2025, 21:30
*/
package ru.risdeveau.pixeldragon.ui.layout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.api.Event
import ru.risdeveau.pixeldragon.api.getAccountData
import ru.risdeveau.pixeldragon.api.getEventsAround
import ru.risdeveau.pixeldragon.api.getMe
@Composable
fun Room(modifier: Modifier = Modifier, rid: String) {
var eventsId by remember { mutableStateOf(listOf<Event>()) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
LaunchedEffect(true) {
coroutineScope.launch {
withContext(Dispatchers.IO) {
val readMark = getAccountData(getMe()!!.userId, rid, "m.fully_read")
val eventsAround = getEventsAround(rid, readMark!!.getString("event_id")) //FIXME: Null check
eventsId = eventsAround.let {
it.before + listOf(it.base) + it.after
}
}
}
}
LazyColumn(modifier = modifier, state = listState, reverseLayout = true) {
items(eventsId.reversed()) { event ->
EventItem(event)
}
item {
if (eventsId.isEmpty()) {
Text("Empty room")
}
}
}
}
@Composable
fun EventItem(event: Event) {
Box (Modifier.fillMaxWidth()) {
when (event.type) {
"m.room.message" -> Column(
Modifier
.align(Alignment.CenterStart)
.padding(4.dp)
.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(16.dp)
)
.padding(4.dp)
) {
Text(event.sender, maxLines = 1, fontWeight = FontWeight.Bold)
Text(event.content.getString("body"))
}
else -> Text(event.type, Modifier.padding(4.dp).background(MaterialTheme.colorScheme.errorContainer).padding(4.dp))
}
}
}

View File

@ -0,0 +1,161 @@
/*
* Created by sweetbread
* Copyright (c) 2025. All rights reserved.
* Last modified 03.03.2025, 20:22
*/
package ru.risdeveau.pixeldragon.ui.layout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil3.compose.SubcomposeAsyncImage
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.risdeveau.pixeldragon.api.getRoom
import ru.risdeveau.pixeldragon.api.getRooms
import ru.risdeveau.pixeldragon.api.mxcToUrl
import ru.risdeveau.pixeldragon.db.Room
import ru.risdeveau.pixeldragon.token
import splitties.init.appCtx
@Composable
fun RoomList(modifier: Modifier = Modifier, navController: NavController) {
var list by remember { mutableStateOf(listOf<String>()) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
// if (itemState.scrollToTop) {
// LaunchedEffect(coroutineScope) {
// Log.e("TAG", "TopCoinsScreen: scrollToTop" )
// listState.scrollToItem(0)
// }
// }
LaunchedEffect(true) {
coroutineScope.launch {
withContext(Dispatchers.IO) {
list = getRooms()
}
}
}
LazyColumn(modifier = modifier, state = listState) {
items(list) { rid ->
RoomItem(rid = rid, navController = navController )
}
item {
if (list.isEmpty()) {
Text("You have no rooms")
}
}
}
}
@Composable
fun RoomItem(modifier: Modifier = Modifier, rid: String, navController: NavController) {
var room by remember { mutableStateOf<Room?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(true) {
scope.launch {
withContext(Dispatchers.IO) {
room = getRoom(rid)
}
}
}
if (room != null) {
Row(
modifier
.padding(8.dp)
.height((52 + 8 * 2).dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp))
.padding(8.dp)
.clickable {
if (room!!.type == "m.space")
navController.navigate("space/$rid")
else
navController.navigate("room/$rid")
}
) {
SubcomposeAsyncImage(
modifier = Modifier
.padding(end = 4.dp)
.height(52.dp)
.width(52.dp)
.let {
if (room!!.type == "m.space")
it.clip(RoundedCornerShape(12.dp))
else
it.clip(CircleShape)
},
model = ImageRequest.Builder(appCtx)
.data(mxcToUrl(room!!.avatarUrl ?: ""))
.httpHeaders(
NetworkHeaders.Builder()
.set("Authorization", "Bearer $token")
.set("Cache-Control", "max-age=86400")
.build()
)
.build(),
contentDescription = room!!.roomId,
contentScale = ContentScale.Crop,
loading = {
CircularProgressIndicator()
}
)
Column {
Text(
room!!.name ?: "Unnamed",
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
fontSize = MaterialTheme.typography.titleLarge.fontSize
)
}
}
} else {
Row(
modifier
.padding(8.dp)
.height(80.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(16.dp))
.padding(8.dp)
) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
}

View File

@ -6,16 +6,23 @@ coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" junitVersion = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.6.1"
kotlinxSerializationJson = "1.7.3"
ktor = "3.1.0" ktor = "3.1.0"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.0" activityCompose = "1.10.0"
composeBom = "2025.02.00" composeBom = "2025.02.00"
navigationCompose = "2.8.8"
room = "2.6.1" room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0" splittiesFunPackAndroidBase = "3.0.0"
[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" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "room" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "room" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-navigation-dynamic-features-fragment = { module = "androidx.navigation:navigation-dynamic-features-fragment", version.ref = "navigationCompose" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "navigationCompose" }
androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigationCompose" }
androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "navigationCompose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } 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" }
@ -34,6 +41,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }