From 40a9cdf344f6491c427c10fb3fef98edbe33471d Mon Sep 17 00:00:00 2001 From: Infi Date: Mon, 20 Mar 2023 01:26:42 +0100 Subject: [PATCH] feat: save last channel, show when no channel/s, less NPEs --- .../java/chat/revolt/api/internals/ULID.kt | 2 +- .../chat/revolt/api/routes/channel/Channel.kt | 24 ++++- .../java/chat/revolt/api/routes/user/User.kt | 8 +- .../chat/revolt/components/chat/Message.kt | 7 +- .../chat/drawer/channel/ChannelList.kt | 102 ++++++++++-------- .../revolt/screens/chat/ChatRouterScreen.kt | 82 ++++++++++++-- .../screens/chat/views/ChannelScreen.kt | 37 +++++-- .../chat/views/NoCurrentChannelScreen.kt | 40 +++++++ app/src/main/res/values/strings.xml | 5 + 9 files changed, 235 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt diff --git a/app/src/main/java/chat/revolt/api/internals/ULID.kt b/app/src/main/java/chat/revolt/api/internals/ULID.kt index fb3c4b5e..d574d432 100644 --- a/app/src/main/java/chat/revolt/api/internals/ULID.kt +++ b/app/src/main/java/chat/revolt/api/internals/ULID.kt @@ -19,7 +19,7 @@ object ULID { 0x59.toChar(), 0x5a.toChar() ) - fun makeSpecial(timestamp: Long, entropy: ByteArray): String { + fun makeSpecial(timestamp: Long, entropy: ByteArray = fetchEntropy()): String { if (timestamp < minTimestamp || timestamp > maxTimestamp) { throw IllegalArgumentException("timestamp out of range: $timestamp") } diff --git a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt index 30fa0e50..94d7e9e8 100644 --- a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt +++ b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt @@ -4,11 +4,17 @@ import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltJson import chat.revolt.api.internals.ULID +import chat.revolt.api.schemas.Channel import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.MessagesInChannel -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType import kotlinx.serialization.builtins.ListSerializer suspend fun fetchMessagesFromChannel( @@ -95,4 +101,16 @@ suspend fun ackChannel(channelId: String, messageId: String = ULID.makeNext()) { RevoltHttp.put("/channels/$channelId/ack/$messageId") { headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) } +} + +suspend fun fetchSingleChannel(channelId: String): Channel { + val response = RevoltHttp.get("/channels/$channelId") { + headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + } + .bodyAsText() + + return RevoltJson.decodeFromString( + Channel.serializer(), + response + ) } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/user/User.kt b/app/src/main/java/chat/revolt/api/routes/user/User.kt index ba2544a3..96fc64ef 100644 --- a/app/src/main/java/chat/revolt/api/routes/user/User.kt +++ b/app/src/main/java/chat/revolt/api/routes/user/User.kt @@ -5,8 +5,8 @@ import chat.revolt.api.RevoltError import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltJson import chat.revolt.api.schemas.User -import io.ktor.client.request.* -import io.ktor.client.statement.* +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText import kotlinx.serialization.SerializationException suspend fun fetchSelf(): User { @@ -50,7 +50,9 @@ suspend fun fetchUser(id: String): User { val user = RevoltJson.decodeFromString(User.serializer(), response) - RevoltAPI.userCache[user.id!!] = user + user.id?.let { + RevoltAPI.userCache[it] = user + } return user } diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index dea244e2..82157d35 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -95,7 +96,7 @@ fun Message( if (message.tail == false) { UserAvatar( username = author.username ?: "", - userId = author.id!!, + userId = author.id ?: message.id ?: ULID.makeSpecial(0), avatar = author.avatar, rawUrl = message.masquerade?.avatar?.let { asJanuaryProxyUrl(it) } ) @@ -107,7 +108,9 @@ fun Message( if (message.tail == false) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = message.masquerade?.name ?: author.username ?: "", + text = message.masquerade?.name + ?: author.username + ?: stringResource(id = R.string.unknown), fontWeight = FontWeight.Bold, color = if (message.masquerade?.colour != null) { WebCompat.parseColour(message.masquerade.colour) diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt index c276b3dd..c0107367 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt @@ -1,7 +1,9 @@ package chat.revolt.components.screens.chat.drawer.channel +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -10,15 +12,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import androidx.navigation.compose.currentBackStackEntryAsState import chat.revolt.R import chat.revolt.api.RevoltAPI import chat.revolt.api.schemas.ChannelType @@ -29,17 +30,19 @@ import kotlinx.coroutines.launch @Composable fun RowScope.ChannelList( serverId: String, - navController: NavController, - drawerState: DoubleDrawerState + drawerState: DoubleDrawerState, + currentChannel: String?, + onChannelClick: (String) -> Unit, + onChannelLongClick: (String) -> Unit, ) { val coroutineScope = rememberCoroutineScope() - val navBackStackEntry by navController.currentBackStackEntryAsState() Surface( tonalElevation = 1.dp, modifier = Modifier .padding(start = 4.dp, top = 8.dp, bottom = 8.dp) .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth(), ) { Column( Modifier @@ -57,9 +60,7 @@ fun RowScope.ChannelList( name = channel.name ?: "GDM #${channel.id}", channelType = ChannelType.Group, - selected = (channel.id == navBackStackEntry?.arguments?.getString( - "channelId" - )), + selected = currentChannel == channel.id, hasUnread = channel.lastMessageID?.let { lastMessageID -> RevoltAPI.unreads.hasUnread( channel.id!!, @@ -67,15 +68,11 @@ fun RowScope.ChannelList( ) } ?: false, onClick = { - navController.navigate("channel/${channel.id}") { - navController.graph.startDestinationRoute?.let { route -> - popUpTo(route) - } - } + onChannelClick(channel.id ?: return@DrawerChannel) coroutineScope.launch { drawerState.focusCenter() } }, onLongClick = { - navController.navigate("channel/${channel.id}/info") + onChannelLongClick(channel.id ?: return@DrawerChannel) } ) } @@ -91,37 +88,52 @@ fun RowScope.ChannelList( modifier = Modifier.padding(16.dp) ) - Column( - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - ) { - server?.channels?.forEach { channelId -> - RevoltAPI.channelCache[channelId]?.let { ch -> - DrawerChannel( - name = ch.name!!, - channelType = ch.channelType!!, - selected = navBackStackEntry?.arguments?.getString( - "channelId" - ) == ch.id, - hasUnread = ch.lastMessageID?.let { lastMessageID -> - RevoltAPI.unreads.hasUnread( - ch.id!!, - lastMessageID - ) - } ?: true, - onClick = { - coroutineScope.launch { drawerState.focusCenter() } - navController.navigate("channel/${ch.id}") { - navController.graph.startDestinationRoute?.let { route -> - popUpTo(route) - } + if (server?.channels?.isEmpty() == true) { + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.no_channels_heading), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + fontSize = 24.sp, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text( + text = stringResource(R.string.no_channels_body), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } else { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + server?.channels?.forEach { channelId -> + RevoltAPI.channelCache[channelId]?.let { ch -> + DrawerChannel( + name = ch.name!!, + channelType = ch.channelType!!, + selected = currentChannel == ch.id, + hasUnread = ch.lastMessageID?.let { lastMessageID -> + RevoltAPI.unreads.hasUnread( + ch.id!!, + lastMessageID + ) + } ?: true, + onClick = { + onChannelClick(ch.id ?: return@DrawerChannel) + coroutineScope.launch { drawerState.focusCenter() } + }, + onLongClick = { + onChannelLongClick(ch.id ?: return@DrawerChannel) } - }, - onLongClick = { - navController.navigate("channel/${ch.id}/menu") - } - ) + ) + } } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index ba3b11dd..e0860fa6 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -34,8 +34,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -55,6 +56,7 @@ import chat.revolt.components.screens.chat.drawer.server.DrawerServer import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator import chat.revolt.components.screens.chat.rememberDoubleDrawerState +import chat.revolt.persistence.KVStorage import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog import chat.revolt.screens.chat.sheets.ChannelContextSheet import chat.revolt.screens.chat.sheets.ChannelInfoSheet @@ -62,35 +64,80 @@ import chat.revolt.screens.chat.sheets.MessageContextSheet import chat.revolt.screens.chat.sheets.StatusSheet import chat.revolt.screens.chat.views.ChannelScreen import chat.revolt.screens.chat.views.HomeScreen +import chat.revolt.screens.chat.views.NoCurrentChannelScreen import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.google.accompanist.navigation.material.ModalBottomSheetLayout import com.google.accompanist.navigation.material.bottomSheet import com.google.accompanist.navigation.material.rememberBottomSheetNavigator +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import javax.inject.Inject -class ChatRouterViewModel : ViewModel() { +@HiltViewModel +class ChatRouterViewModel @Inject constructor( + private val kvStorage: KVStorage +) : ViewModel() { private var _currentServer = mutableStateOf("home") val currentServer: String get() = _currentServer.value - fun setCurrentServer(serverId: String) { + private var _currentChannel = mutableStateOf(null) + val currentChannel: String? + get() = _currentChannel.value + + init { + viewModelScope.launch { + _currentServer.value = kvStorage.get("currentServer") ?: "home" + _currentChannel.value = kvStorage.get("currentChannel") + } + } + + private fun setCurrentServer(serverId: String, save: Boolean = true) { _currentServer.value = serverId + + if (save) viewModelScope.launch { + kvStorage.set("currentServer", serverId) + } + } + + private fun setCurrentChannel(channelId: String) { + _currentChannel.value = channelId + + viewModelScope.launch { + kvStorage.set("currentChannel", channelId) + } } fun navigateToServer(serverId: String, navController: NavController) { - setCurrentServer(serverId) - if (serverId == "home") { navController.navigate("home") { navController.graph.startDestinationRoute?.let { route -> popUpTo(route) } } + setCurrentServer("home") return } val channelId = RevoltAPI.serverCache[serverId]?.channels?.firstOrNull() + + setCurrentServer(serverId, channelId != null) + + if (channelId != null) { + navigateToChannel(channelId, navController) + } else { + navController.navigate("no_current_channel") { + navController.graph.startDestinationRoute?.let { route -> + popUpTo(route) + } + } + } + } + + fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) { + if (!pure) setCurrentChannel(channelId) + navController.navigate("channel/$channelId") { navController.graph.startDestinationRoute?.let { route -> popUpTo(route) @@ -101,7 +148,7 @@ class ChatRouterViewModel : ViewModel() { @OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalComposeUiApi::class) @Composable -fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = viewModel()) { +fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hiltViewModel()) { val drawerState = rememberDoubleDrawerState() val scope = rememberCoroutineScope() val context = LocalContext.current @@ -120,6 +167,16 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie } } + LaunchedEffect(viewModel.currentChannel) { + snapshotFlow { viewModel.currentChannel } + .distinctUntilChanged() + .collect { channelId -> + if (channelId != null) { + viewModel.navigateToChannel(channelId, navController, pure = true) + } + } + } + ModalBottomSheetLayout( sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetBackgroundColor = MaterialTheme.colorScheme.surface, @@ -207,8 +264,14 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie Crossfade(targetState = viewModel.currentServer) { ChannelList( serverId = it, - navController = navController, - drawerState = drawerState + drawerState = drawerState, + currentChannel = viewModel.currentChannel, + onChannelClick = { channelId -> + viewModel.navigateToChannel(channelId, navController) + }, + onChannelLongClick = { channelId -> + navController.navigate("channel/$channelId/info") + }, ) } } @@ -239,6 +302,9 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie ) } } + composable("no_current_channel") { + NoCurrentChannelScreen() + } bottomSheet("channel/{channelId}/info") { backStackEntry -> val channelId = backStackEntry.arguments?.getString("channelId") diff --git a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt index 82f9109d..67528a19 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt @@ -43,6 +43,7 @@ import chat.revolt.api.realtime.frames.receivable.MessageFrame import chat.revolt.api.routes.channel.SendMessageReply import chat.revolt.api.routes.channel.ackChannel import chat.revolt.api.routes.channel.fetchMessagesFromChannel +import chat.revolt.api.routes.channel.fetchSingleChannel import chat.revolt.api.routes.channel.sendMessage import chat.revolt.api.routes.microservices.autumn.FileArgs import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE @@ -81,6 +82,8 @@ class ChannelScreenViewModel : ViewModel() { val channel: Channel? get() = _channel + private var _uiCallbackRegistered by mutableStateOf(false) + private var _channelCallback = mutableStateOf(null) private val channelCallback: RealtimeSocket.ChannelCallback? get() = _channelCallback.value @@ -222,8 +225,16 @@ class ChannelScreenViewModel : ViewModel() { _channelCallback.value = ChannelScreenCallback() RealtimeSocket.registerChannelCallback(channel!!.id!!, channelCallback!!) - _uiCallbackReceiver.value = UiCallbackReceiver() - UiCallbacks.registerReceiver(uiCallbackReceiver!!) + if (!_uiCallbackRegistered) { + _uiCallbackReceiver.value = UiCallbackReceiver() + UiCallbacks.registerReceiver(uiCallbackReceiver!!) + _uiCallbackRegistered = true + } else { + Log.d( + "ChannelScreenViewModel", + "UI Callbacks already registered but trying to register again. Ignoring but this is a bug." + ) + } } fun fetchMessages() { @@ -300,16 +311,22 @@ class ChannelScreenViewModel : ViewModel() { } 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 - } + viewModelScope.launch { + if (id !in RevoltAPI.channelCache) { + val channel = fetchSingleChannel(id) + _channel = channel + RevoltAPI.channelCache[id] = channel + } else { + _channel = RevoltAPI.channelCache[id] + } - registerCallbacks() + registerCallbacks() - if (channel?.lastMessageID != null) { - ackNewest() + if (_channel?.lastMessageID != null) { + ackNewest() + } else { + Log.d("ChannelScreen", "No last message ID, not acking.") + } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt new file mode 100644 index 00000000..b128fe55 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt @@ -0,0 +1,40 @@ +package chat.revolt.screens.chat.views + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.revolt.R + +@Composable +fun NoCurrentChannelScreen() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(64.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.no_active_channel), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + fontSize = 24.sp, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text( + text = stringResource(R.string.no_active_channel_body), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a16f502f..c9da4601 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,6 +89,11 @@ Add server + Bit awkward. + There aren\'t any channels in this server. Not even a welcome channel. How rude. + You\'re not in a channel right now. + Select a server from the left to get started. If you\'re feeling adventurous, you can create a new server. + %1$s\'s avatar Direct Message