diff --git a/app/build.gradle b/app/build.gradle index 417b57dc..a58f5cf2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,7 +26,8 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } 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 7d0779f4..ba2544a3 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 @@ -31,10 +31,15 @@ suspend fun fetchSelf(): User { } suspend fun fetchUser(id: String): User { - val response = RevoltHttp.get("/users/$id") { + val res = RevoltHttp.get("/users/$id") { headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) } - .bodyAsText() + + if (res.status.value == 404) { + return User.getPlaceholder(id) + } + + val response = res.bodyAsText() try { val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) diff --git a/app/src/main/java/chat/revolt/api/schemas/User.kt b/app/src/main/java/chat/revolt/api/schemas/User.kt index ec0a2182..5b758daf 100644 --- a/app/src/main/java/chat/revolt/api/schemas/User.kt +++ b/app/src/main/java/chat/revolt/api/schemas/User.kt @@ -37,6 +37,22 @@ data class User( online = partial.online ?: online ) } + + companion object { + fun getPlaceholder(forId: String) = User( + id = forId, + username = "Unknown User", + avatar = null, + badges = 0, + status = null, + profile = null, + flags = 0, + privileged = false, + bot = null, + relationship = null, + online = false + ) + } } @Serializable diff --git a/app/src/main/java/chat/revolt/components/chat/MessageField.kt b/app/src/main/java/chat/revolt/components/chat/MessageField.kt index 48a77d04..a05baa41 100644 --- a/app/src/main/java/chat/revolt/components/chat/MessageField.kt +++ b/app/src/main/java/chat/revolt/components/chat/MessageField.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Send import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -75,7 +75,7 @@ fun MessageField( modifier = Modifier.height(56.dp) ) { Icon( - Icons.Default.KeyboardArrowLeft, + Icons.Default.KeyboardArrowRight, contentDescription = stringResource(id = R.string.show_more_alt), modifier = Modifier .size(24.dp + 8.dp) diff --git a/app/src/main/java/chat/revolt/components/generic/RemoteImage.kt b/app/src/main/java/chat/revolt/components/generic/RemoteImage.kt index 612f426d..9f634775 100644 --- a/app/src/main/java/chat/revolt/components/generic/RemoteImage.kt +++ b/app/src/main/java/chat/revolt/components/generic/RemoteImage.kt @@ -11,6 +11,7 @@ import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder +import coil.memory.MemoryCache import coil.request.ImageRequest @Composable @@ -20,19 +21,28 @@ fun RemoteImage( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop ) { + val context = LocalContext.current + AsyncImage( - model = ImageRequest.Builder(LocalContext.current) + model = ImageRequest.Builder(context) .data(url) .crossfade(true) .build(), - imageLoader = ImageLoader.Builder(LocalContext.current).components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) + imageLoader = ImageLoader.Builder(context) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) } - add(SvgDecoder.Factory()) - }.build(), + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(.25) + .build() + } + .build(), contentDescription = description, contentScale = contentScale, modifier = modifier diff --git a/app/src/main/java/chat/revolt/components/screens/chat/DrawerChannel.kt b/app/src/main/java/chat/revolt/components/screens/chat/DrawerChannel.kt new file mode 100644 index 00000000..e2f028cf --- /dev/null +++ b/app/src/main/java/chat/revolt/components/screens/chat/DrawerChannel.kt @@ -0,0 +1,75 @@ +package chat.revolt.components.screens.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import chat.revolt.api.schemas.ChannelType +import chat.revolt.R + +@Composable +fun DrawerChannel( + channelType: ChannelType, + name: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 8.dp) + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(if (selected) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 16.dp) + ) { + when (channelType) { + ChannelType.TextChannel -> { + Icon( + modifier = Modifier.padding(end = 8.dp), + painter = painterResource(R.drawable.ic_pound_24dp), + contentDescription = stringResource(R.string.channel_text) + ) + } + ChannelType.VoiceChannel -> { + Icon( + modifier = Modifier.padding(end = 8.dp), + painter = painterResource(R.drawable.ic_volume_up_24dp), + contentDescription = stringResource(R.string.channel_voice) + ) + } + ChannelType.SavedMessages -> { + Icon( + modifier = Modifier.padding(end = 8.dp), + painter = painterResource(R.drawable.ic_note_24dp), + contentDescription = stringResource(R.string.channel_notes) + ) + } + else -> { + Icon( + modifier = Modifier.padding(end = 8.dp), + imageVector = Icons.Default.List, + contentDescription = "Channel" + ) + } + } + Text( + text = name, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} \ No newline at end of file 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 d41a8b00..0b503f4f 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -1,16 +1,23 @@ package chat.revolt.screens.chat +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -19,19 +26,20 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import chat.revolt.api.REVOLT_FILES import chat.revolt.api.RevoltAPI import chat.revolt.components.generic.RemoteImage -import chat.revolt.components.generic.drawableResource import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.R +import chat.revolt.api.schemas.ChannelType +import chat.revolt.components.screens.chat.DrawerChannel import chat.revolt.screens.chat.views.ChannelScreen import kotlinx.coroutines.launch class ChatRouterViewModel : ViewModel() { - private var _currentServer = - mutableStateOf(RevoltAPI.serverCache.values.firstOrNull()?.id ?: "home") + private var _currentServer = mutableStateOf("home") val currentServer: String get() = _currentServer.value @@ -50,71 +58,133 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie val channelDrawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() - DismissibleNavigationDrawer(drawerState = channelDrawerState, drawerContent = { - ModalDrawerSheet { - Column(Modifier.fillMaxWidth()) { - Row { - Column(Modifier.verticalScroll(rememberScrollState())) { - RemoteImage( - url = drawableResource(R.drawable.ic_launcher_monochrome), + DismissibleNavigationDrawer( + drawerState = channelDrawerState, + drawerContent = { + ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) { + Column(Modifier.fillMaxWidth()) { + Row { + Column( modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .clickable { viewModel.goToHome() }, - description = "Home", - ) - RevoltAPI.serverCache.values.forEach { server -> - server.icon?.let { icon -> - RemoteImage( - url = "$REVOLT_FILES/icons/${icon.id!!}/server.png?max_side=256", - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .clickable { viewModel.setCurrentServer(server.id!!) }, - description = "${server.name}" + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.surface) + ) { + IconButton( + onClick = { viewModel.goToHome() }, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + ) { + Icon( + Icons.Default.Home, + contentDescription = stringResource(id = R.string.home), + modifier = Modifier.padding(4.dp) ) } - } - } - Column( - Modifier - .weight(1f) - ) { - if (viewModel.currentServer != "home") { - val server = RevoltAPI.serverCache[viewModel.currentServer] - Text( - text = server?.name ?: "Unknown Server", - fontWeight = FontWeight.Black, - fontSize = 24.sp - ) + RevoltAPI.serverCache.values.forEach { server -> + if (server.name == null) return@forEach - Column( - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - ) { - server?.channels?.forEach { channelId -> - RevoltAPI.channelCache[channelId]?.let { + if (server.icon != null) { + RemoteImage( + url = "$REVOLT_FILES/icons/${server.icon.id!!}/server.png?max_side=256", + modifier = Modifier + .padding(8.dp) + .size(48.dp) + .clip(CircleShape) + .clickable { viewModel.setCurrentServer(server.id!!) }, + description = "${server.name}" + ) + } else { + // return a placeholder icon, currently the first letter of the server name in a circle + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { viewModel.setCurrentServer(server.id!!) } + ) { Text( - text = it.name ?: "Unnamed Channel", - modifier = Modifier.clickable { - scope.launch { channelDrawerState.close() } - navController.navigate("channel/${it.id}") - } + text = server.name.first().toString(), + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) } } } - } else { - Text(text = "Home not implemented!") + } + + Crossfade(targetState = viewModel.currentServer) { + Column( + Modifier + .weight(1f) + ) { + if (it == "home") { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.DirectMessage } + .forEach { channel -> + DrawerChannel( + name = "DM #${channel.id}", // TODO get user or group name + channelType = ChannelType.DirectMessage, + selected = channel.id == (navBackStackEntry?.arguments?.getString( + "channelId" + ) ?: false), + onClick = { + navController.navigate("channel/${channel.id}") + scope.launch { + channelDrawerState.close() + } + } + ) + } + } + } else { + val server = RevoltAPI.serverCache[it] + + Text( + text = server?.name ?: stringResource(R.string.unknown), + fontWeight = FontWeight.Black, + fontSize = 24.sp, + 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, + onClick = { + scope.launch { channelDrawerState.close() } + navController.navigate("channel/${ch.id}") + }) + } + } + } + } + } } } } } } - }) { + ) { Column(Modifier.fillMaxSize()) { NavHost(navController = navController, startDestination = "home") { composable("home") { 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 00e93f4a..a62cfdb6 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 @@ -1,15 +1,17 @@ package chat.revolt.screens.chat.views import android.util.Log -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,6 +29,8 @@ import chat.revolt.api.schemas.Message as MessageSchema import chat.revolt.components.chat.Message import kotlinx.coroutines.launch import chat.revolt.R +import chat.revolt.RevoltTweenFloat +import chat.revolt.RevoltTweenInt import chat.revolt.api.routes.channel.fetchMessagesFromChannel import chat.revolt.components.chat.MessageField @@ -181,30 +185,48 @@ fun ChannelScreen( } Column { - Text(text = "#" + channel.name!!) + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + ) { + Text( + text = channel.name ?: channel.id!!, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(16.dp) + ) + } - Divider() - - // Column nesting is needed to make the vertical scroll work properly - Column(Modifier.weight(1f)) { - Column(Modifier.verticalScroll(scrollState)) { - viewModel.renderableMessages.forEach { - Message(message = it) - } + LazyColumn(Modifier.weight(1f)) { + items(viewModel.renderableMessages) { message -> + Message(message) } } - AnimatedVisibility(visible = viewModel.typingUsers.isNotEmpty()) { + AnimatedVisibility( + visible = viewModel.typingUsers.isNotEmpty(), + enter = slideInVertically( + animationSpec = RevoltTweenInt, + initialOffsetY = { it } + ) + fadeIn(animationSpec = RevoltTweenFloat), + exit = slideOutVertically( + animationSpec = RevoltTweenInt, + targetOffsetY = { it } + ) + fadeOut(animationSpec = RevoltTweenFloat) + ) { Row( Modifier - .padding(all = 4.dp) .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxWidth() + .padding(all = 4.dp) ) { Text( text = stringResource( id = viewModel.typingMessageResource(), viewModel.getTypingUsernames() - ) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -218,7 +240,7 @@ fun ChannelScreen( onMessageContentChange = viewModel::setMessageContent, onSendMessage = viewModel::sendPendingMessage, channelType = it, - channelName = channel.name + channelName = channel.name ?: channel.id!! ) } } diff --git a/app/src/main/res/drawable/ic_account_details_24dp.xml b/app/src/main/res/drawable/ic_account_details_24dp.xml new file mode 100644 index 00000000..d79b9db8 --- /dev/null +++ b/app/src/main/res/drawable/ic_account_details_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_note_24dp.xml b/app/src/main/res/drawable/ic_note_24dp.xml new file mode 100644 index 00000000..5d60a80c --- /dev/null +++ b/app/src/main/res/drawable/ic_note_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pound_24dp.xml b/app/src/main/res/drawable/ic_pound_24dp.xml new file mode 100644 index 00000000..78c07e44 --- /dev/null +++ b/app/src/main/res/drawable/ic_pound_24dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_24dp.xml b/app/src/main/res/drawable/ic_volume_up_24dp.xml new file mode 100644 index 00000000..3ce9f287 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f5f9597..c37dc587 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,8 +74,17 @@ Welcome to Revolt\'s in-progress Android experience! Select a server and channel by swiping from the left. + Unknown + Home + %1$s\'s avatar + Direct Message + Text Channel + Voice Channel + Group + Notes + Message @%1$s Message #%1$s Message #%1$s