diff --git a/app/.gitignore b/app/.gitignore index 42afabfd..956c004d 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/release \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/MainActivity.kt b/app/src/main/java/chat/revolt/MainActivity.kt index ec21ecb9..04cad90e 100644 --- a/app/src/main/java/chat/revolt/MainActivity.kt +++ b/app/src/main/java/chat/revolt/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index d5a5369b..125f4505 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -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> = 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") + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/schemas/Channel.kt b/app/src/main/java/chat/revolt/api/schemas/Channel.kt index b301480e..62ebd241 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Channel.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Channel.kt @@ -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? = null, + @SerialName("default_permissions") val defaultPermissions: DefaultPermissions? = null, val nsfw: Boolean? = null, val type: String? = null, // this is _only_ used for websocket events! diff --git a/app/src/main/java/chat/revolt/api/schemas/Messages.kt b/app/src/main/java/chat/revolt/api/schemas/Messages.kt index 80743867..94987636 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Messages.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Messages.kt @@ -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? = null, val mentions: List? = 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( diff --git a/app/src/main/java/chat/revolt/screens/chat/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChannelScreen.kt new file mode 100644 index 00000000..f872cf55 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/ChannelScreen.kt @@ -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(null) + val channel: Channel? + get() = _channel + + private var _callbacks = mutableStateOf(null) + val callbacks: RealtimeSocket.ChannelCallback? + get() = _callbacks.value + + private var _renderableMessages = mutableStateListOf() + val renderableMessages: List + 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!!) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/HomeScreen.kt b/app/src/main/java/chat/revolt/screens/chat/HomeScreen.kt index 8f5de315..b0fef8c0 100644 --- a/app/src/main/java/chat/revolt/screens/chat/HomeScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/HomeScreen.kt @@ -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") + } } } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/ui/theme/Theme.kt b/app/src/main/java/chat/revolt/ui/theme/Theme.kt index 1d808edc..e355e41a 100644 --- a/app/src/main/java/chat/revolt/ui/theme/Theme.kt +++ b/app/src/main/java/chat/revolt/ui/theme/Theme.kt @@ -28,6 +28,8 @@ val DarkColorScheme = darkColorScheme( onBackground = Color(FOREGROUND), surfaceVariant = Color(0xff172333), onSurfaceVariant = Color(FOREGROUND), + surface = Color(0xff111a26), + onSurface = Color(FOREGROUND), ) @Composable