feat: save last channel, show when no channel/s, less NPEs

This commit is contained in:
Infi 2023-03-20 01:26:42 +01:00
parent 7bf4d3aadf
commit 40a9cdf344
9 changed files with 235 additions and 72 deletions

View File

@ -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")
}

View File

@ -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
)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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")
}
)
)
}
}
}
}

View File

@ -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<String?>(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")

View File

@ -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<RealtimeSocket.ChannelCallback?>(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.")
}
}
}

View File

@ -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,
)
}
}

View File

@ -89,6 +89,11 @@
<string name="server_plus_alt">Add server</string>
<string name="no_channels_heading">Bit awkward.</string>
<string name="no_channels_body">There aren\'t any channels in this server. Not even a welcome channel. How rude.</string>
<string name="no_active_channel">You\'re not in a channel right now.</string>
<string name="no_active_channel_body">Select a server from the left to get started. If you\'re feeling adventurous, you can create a new server.</string>
<string name="avatar_alt">%1$s\'s avatar</string>
<string name="channel_dm">Direct Message</string>