feat: receive messages

this is honestly half a prototype
img: https://autumn.revolt.chat/attachments/GbiNbIf_JysZ10m4ULxGl4SfoOZiK8lj5ZL8fMs39r/image.png
This commit is contained in:
Infi 2022-12-26 04:02:32 +01:00
parent a5288e4ed7
commit 77138cd521
8 changed files with 282 additions and 78 deletions

3
app/.gitignore vendored
View File

@ -1 +1,2 @@
/build
/build
/release

View File

@ -16,6 +16,7 @@ import chat.revolt.screens.SplashScreen
import chat.revolt.screens.about.AboutScreen
import chat.revolt.screens.about.AttributionScreen
import chat.revolt.screens.about.PlaceholderScreen
import chat.revolt.screens.chat.ChannelScreen
import chat.revolt.screens.chat.HomeScreen
import chat.revolt.screens.login.GreeterScreen
import chat.revolt.screens.login.LoginScreen
@ -92,6 +93,11 @@ fun AppEntrypoint() {
}
composable("chat/home") { HomeScreen(navController) }
composable("chat/channel/{channelId}") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelScreen(navController, channelId)
}
composable("about") { AboutScreen(navController) }
composable("about/oss") { AttributionScreen(navController) }

View File

@ -1,6 +1,7 @@
package chat.revolt.api.realtime
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import chat.revolt.api.REVOLT_WEBSOCKET
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
@ -94,6 +95,43 @@ object RealtimeSocket {
RevoltAPI.emojiCache[emoji.id!!] = emoji
}
}
"Message" -> {
val messageFrame = RevoltJson.decodeFromString(MessageFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received message frame for ${messageFrame.id} in channel ${messageFrame.channel}."
)
RevoltAPI.messageCache[messageFrame.id!!] = messageFrame
channelCallbacks[messageFrame.channel]?.forEach { callback ->
callback.onMessage(messageFrame)
}
}
"ChannelStartTyping" -> {
val typingFrame =
RevoltJson.decodeFromString(ChannelStartTypingFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received channel start typing frame for ${typingFrame.id} from ${typingFrame.user}."
)
channelCallbacks[typingFrame.id]?.forEach { callback ->
callback.onStartTyping(typingFrame)
}
}
"ChannelStopTyping" -> {
val typingFrame =
RevoltJson.decodeFromString(ChannelStopTypingFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received channel stop typing frame for ${typingFrame.id} from ${typingFrame.user}."
)
channelCallbacks[typingFrame.id]?.forEach { callback ->
callback.onStopTyping(typingFrame)
}
}
"UserUpdate" -> {
val userUpdateFrame =
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)
@ -105,4 +143,26 @@ object RealtimeSocket {
}
}
}
interface ChannelCallback {
fun onStartTyping(typing: ChannelStartTypingFrame)
fun onStopTyping(typing: ChannelStopTypingFrame)
fun onMessage(message: MessageFrame)
}
private val channelCallbacks: MutableMap<String, List<ChannelCallback>> = mutableStateMapOf()
fun registerChannelCallback(channelId: String, callback: ChannelCallback) {
val callbacks = channelCallbacks[channelId] ?: emptyList()
channelCallbacks[channelId] = callbacks + callback
Log.d("RealtimeSocket", "Registered channel callback for $channelId.")
}
fun unregisterChannelCallback(channelId: String, callback: ChannelCallback) {
val callbacks = channelCallbacks[channelId] ?: emptyList()
channelCallbacks[channelId] = callbacks - callback
Log.d("RealtimeSocket", "Unregistered channel callback for $channelId")
}
}

View File

@ -40,7 +40,9 @@ data class Channel(
val active: Boolean? = null,
val permissions: Long? = null,
val server: String? = null,
@SerialName("role_permissions")
val rolePermissions: Map<String, DefaultPermissions>? = null,
@SerialName("default_permissions")
val defaultPermissions: DefaultPermissions? = null,
val nsfw: Boolean? = null,
val type: String? = null, // this is _only_ used for websocket events!

View File

@ -1,5 +1,6 @@
package chat.revolt.api.schemas
import chat.revolt.api.RevoltAPI
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -18,7 +19,11 @@ data class Message(
val embeds: List<Embed>? = null,
val mentions: List<String>? = null,
val type: String? = null, // this is _only_ used for websocket events!
)
) {
fun getAuthor(): User? {
return author?.let { RevoltAPI.userCache[it] }
}
}
@Serializable
data class Embed(

View File

@ -0,0 +1,95 @@
package chat.revolt.screens.chat
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.navigation.NavController
import chat.revolt.api.RevoltAPI
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message
class ChannelScreenViewModel : ViewModel() {
private var _channel by mutableStateOf<Channel?>(null)
val channel: Channel?
get() = _channel
private var _callbacks = mutableStateOf<RealtimeSocket.ChannelCallback?>(null)
val callbacks: RealtimeSocket.ChannelCallback?
get() = _callbacks.value
private var _renderableMessages = mutableStateListOf<Message>()
val renderableMessages: List<Message>
get() = _renderableMessages
inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback {
override fun onMessage(message: MessageFrame) {
Log.d("ChannelScreen", "onMessage: $message")
_renderableMessages.add(message)
}
override fun onStartTyping(typing: ChannelStartTypingFrame) {
Log.d("ChannelScreen", "onStartTyping: $typing")
}
override fun onStopTyping(typing: ChannelStopTypingFrame) {
Log.d("ChannelScreen", "onStopTyping: $typing")
}
}
private fun registerCallback() {
_callbacks.value = ChannelScreenCallback()
RealtimeSocket.registerChannelCallback(channel!!.id!!, callbacks!!)
}
fun fetchChannel(id: String) {
if (id in RevoltAPI.channelCache) {
_channel = RevoltAPI.channelCache[id]
} else {
Log.e("ChannelScreen", "Channel $id not in cache, for now this is fatal!") // FIXME
}
registerCallback()
}
}
@Composable
fun ChannelScreen(
navController: NavController,
channelId: String,
viewModel: ChannelScreenViewModel = hiltViewModel()
) {
val channel = viewModel.channel
LaunchedEffect(channelId) {
viewModel.fetchChannel(channelId)
}
DisposableEffect(channelId) {
onDispose {
viewModel.callbacks?.let {
RealtimeSocket.unregisterChannelCallback(channelId, it)
}
}
}
if (channel == null) {
CircularProgressIndicator()
return
}
Column {
Text(text = channel.name!!)
viewModel.renderableMessages.forEach {
Text(text = "[" + it.getAuthor()!!.username + "] " + it.content!!)
}
}
}

View File

@ -6,13 +6,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -61,52 +56,52 @@ class HomeScreenViewModel @Inject constructor(
messageContent
)
}
setMessageContent("")
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) {
val user = RevoltAPI.userCache[RevoltAPI.selfId]
Column() {
Text(
text = "Home (placeholder)",
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
fontSize = 24.sp
),
modifier = Modifier
.padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(),
)
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxSize()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
user?.let {
Row {
RemoteImage(
url = "${REVOLT_FILES}/avatars/${it.avatar?.id}/user.png",
modifier = Modifier
.size(50.dp)
.clip(CircleShape),
description = "Avatar for ${it.username} (placeholder!)"
)
Column(modifier = Modifier.padding(start = 10.dp)) {
it.username?.let { it1 -> Text(text = it1) }
it.id?.let { it1 -> Text(text = it1) }
}
}
}
val channelDrawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
DismissibleNavigationDrawer(drawerState = channelDrawerState, drawerContent = {
ModalDrawerSheet {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "User cache",
text = "Revolt Lounge",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Black)
)
Divider()
Column(Modifier.verticalScroll(rememberScrollState())) {
RevoltAPI.channelCache.values
.filter { channel ->
channel.server == "01F7ZSBSFHQ8TA81725KQCSDDP"
}
.forEach { channel ->
NavigationDrawerItem(
selected = false,
label = { Text(text = "#" + channel.name) },
onClick = {
scope.launch {
channelDrawerState.close()
navController.navigate("chat/channel/${channel.id}")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
}
}
}
}) {
Column() {
Text(
text = "Home (placeholder)",
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
@ -118,42 +113,80 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi
)
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.height(200.dp)
.padding(10.dp)
.fillMaxSize()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
RevoltAPI.userCache.forEach { (_, user) ->
Text(text = user.username ?: user.id ?: "null")
}
}
user?.let {
Row {
RemoteImage(
url = "${REVOLT_FILES}/avatars/${it.avatar?.id}/user.png",
modifier = Modifier
.size(50.dp)
.clip(CircleShape),
description = "Avatar for ${it.username} (placeholder!)"
)
Column() {
FormTextField(
value = viewModel.messageContent,
label = "Content",
modifier = Modifier.fillMaxWidth(),
onChange = viewModel::setMessageContent
)
LinkOnHome(
heading = "Send",
icon = Icons.Filled.Send,
onClick = viewModel::sendMessage
)
}
}
Button(
onClick = {
viewModel.logout()
navController.navigate("login/greeting") {
popUpTo("chat/home") {
inclusive = true
Column(modifier = Modifier.padding(start = 10.dp)) {
it.username?.let { it1 -> Text(text = it1) }
it.id?.let { it1 -> Text(text = it1) }
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 30.dp, top = 5.dp, start = 20.dp, end = 20.dp)
) {
Text("Logout")
Text(
text = "User cache",
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
fontSize = 24.sp
),
modifier = Modifier
.padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(),
)
Column(modifier = Modifier.height(200.dp)) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
RevoltAPI.userCache.forEach { (_, user) ->
Text(text = user.username ?: user.id ?: "null")
}
}
}
Column() {
FormTextField(
value = viewModel.messageContent,
label = "Content",
modifier = Modifier.fillMaxWidth(),
onChange = viewModel::setMessageContent
)
LinkOnHome(
heading = "Send",
icon = Icons.Filled.Send,
onClick = viewModel::sendMessage
)
}
}
Button(
onClick = {
viewModel.logout()
navController.navigate("login/greeting") {
popUpTo("chat/home") {
inclusive = true
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 30.dp, top = 5.dp, start = 20.dp, end = 20.dp)
) {
Text("Logout")
}
}
}
}

View File

@ -28,6 +28,8 @@ val DarkColorScheme = darkColorScheme(
onBackground = Color(FOREGROUND),
surfaceVariant = Color(0xff172333),
onSurfaceVariant = Color(FOREGROUND),
surface = Color(0xff111a26),
onSurface = Color(FOREGROUND),
)
@Composable