diff --git a/app/build.gradle b/app/build.gradle index 95dea4a1..dd487bd4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,6 +101,7 @@ android { } lintOptions { abortOnError false + disable 'MissingTranslation' } namespace 'chat.revolt' } diff --git a/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt b/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt new file mode 100644 index 00000000..32f354df --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/ChannelUtils.kt @@ -0,0 +1,15 @@ +package chat.revolt.api.internals + +import chat.revolt.api.RevoltAPI +import chat.revolt.api.schemas.Channel + +object ChannelUtils { + fun resolveDMName(channel: Channel): String? { + return channel.name + ?: RevoltAPI.userCache[channel.recipients?.first { u -> u != RevoltAPI.selfId }]?.username + } + + fun resolveDMPartner(channel: Channel): String? { + return channel.recipients?.first { u -> u != RevoltAPI.selfId } + } +} \ No newline at end of file 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 c656e503..279594ff 100644 --- a/app/src/main/java/chat/revolt/components/chat/MessageField.kt +++ b/app/src/main/java/chat/revolt/components/chat/MessageField.kt @@ -1,10 +1,17 @@ package chat.revolt.components.chat -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -12,7 +19,13 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -107,7 +120,7 @@ fun MessageField( Icon( Icons.Default.Add, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - contentDescription = stringResource(id = R.string.unknown), + contentDescription = stringResource(id = R.string.add_attachment_alt), modifier = Modifier .clip(CircleShape) .size(32.dp) @@ -132,7 +145,7 @@ fun MessageField( Icon( Icons.Default.Send, tint = MaterialTheme.colorScheme.primary, - contentDescription = stringResource(id = R.string.unknown), + contentDescription = stringResource(id = R.string.send_alt), modifier = Modifier .clip(CircleShape) .size(32.dp) diff --git a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt index eab7092d..c02165e1 100644 --- a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt +++ b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape 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 @@ -123,6 +124,65 @@ fun UserAvatar( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GroupIcon( + name: String, + modifier: Modifier = Modifier, + icon: AutumnResource? = null, + rawUrl: String? = null, + size: Dp = 40.dp, + onLongClick: (() -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + Box( + modifier = modifier + .size(size), + contentAlignment = Alignment.BottomEnd + ) { + if (icon != null) { + RemoteImage( + url = rawUrl ?: "$REVOLT_FILES/icons/${icon.id!!}/group.png", + contentScale = ContentScale.Crop, + description = stringResource(id = R.string.avatar_alt, name), + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .size(size) + .then( + if (onLongClick != null || onClick != null) Modifier + .combinedClickable( + onClick = { onClick?.invoke() }, + onLongClick = { onLongClick?.invoke() } + ) + else Modifier + ) + ) + } else { + Box( + modifier = Modifier + .size(size) + .then( + if (onLongClick != null || onClick != null) Modifier + .combinedClickable( + onClick = { onClick?.invoke() }, + onLongClick = { onLongClick?.invoke() } + ) + else Modifier + ) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.primary) + ) { + Text( + text = name.first().toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } +} + @Composable fun UserAvatarWidthPlaceholder( size: Dp = 40.dp, diff --git a/app/src/main/java/chat/revolt/components/screens/chat/ChannelHeader.kt b/app/src/main/java/chat/revolt/components/screens/chat/ChannelHeader.kt index 7859d079..d36edc64 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/ChannelHeader.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/ChannelHeader.kt @@ -22,7 +22,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.revolt.R +import chat.revolt.api.internals.ChannelUtils import chat.revolt.api.schemas.Channel +import chat.revolt.api.schemas.ChannelType @Composable fun ChannelHeader( @@ -64,7 +66,13 @@ fun ChannelHeader( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = channel.name ?: "Ch #${channel.id}", + text = channel.name + ?: ChannelUtils.resolveDMName(channel) + ?: if (channel.channelType == ChannelType.SavedMessages) { + stringResource(R.string.channel_notes) + } else { + stringResource(R.string.unknown) + }, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 e3a89679..41b663ef 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 @@ -3,12 +3,12 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -19,7 +19,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,21 +29,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.revolt.R import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.ChannelUtils import chat.revolt.api.schemas.ChannelType +import chat.revolt.components.generic.presenceFromStatus import chat.revolt.components.screens.chat.drawer.server.DrawerChannel import chat.revolt.sheets.ChannelContextSheet -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun RowScope.ChannelList( serverId: String, - drawerState: DrawerState, + currentDestination: String?, currentChannel: String?, onChannelClick: (String) -> Unit, + onSpecialClick: (String) -> Unit, ) { - val coroutineScope = rememberCoroutineScope() - var channelContextSheetShown by remember { mutableStateOf(false) } var channelContextSheetTarget by remember { mutableStateOf("") } @@ -67,6 +66,13 @@ fun RowScope.ChannelList( } } + val dmAbleChannels = + RevoltAPI.channelCache.values + .filter { it.channelType == ChannelType.DirectMessage || it.channelType == ChannelType.Group } + .filter { if (it.channelType == ChannelType.DirectMessage) it.active == true else true } + .sortedBy { it.lastMessageID ?: it.id } + .reversed() + Surface( tonalElevation = 1.dp, modifier = Modifier @@ -74,82 +80,153 @@ fun RowScope.ChannelList( .clip(RoundedCornerShape(16.dp)) .fillMaxWidth(), ) { - Column( + LazyColumn( Modifier .weight(1f) + .fillMaxSize(), ) { if (serverId == "home") { - Column( - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) + item( + key = "header" ) { - RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.Group } - .forEach { channel -> - DrawerChannel( - name = channel.name - ?: "GDM #${channel.id}", - channelType = ChannelType.Group, - selected = currentChannel == channel.id, - hasUnread = channel.lastMessageID?.let { lastMessageID -> - RevoltAPI.unreads.hasUnread( - channel.id!!, - lastMessageID - ) - } ?: false, - onClick = { - onChannelClick(channel.id ?: return@DrawerChannel) - coroutineScope.launch { drawerState.close() } - }, - onLongClick = { - channelContextSheetTarget = channel.id ?: return@DrawerChannel - channelContextSheetShown = true - } + Text( + text = stringResource(R.string.direct_messages), + style = MaterialTheme.typography.labelLarge, + fontSize = 24.sp, + modifier = Modifier.padding(16.dp) + ) + } + + item( + key = "home" + ) { + DrawerChannel( + name = stringResource(R.string.home), + channelType = ChannelType.TextChannel, + selected = currentDestination == "home", + hasUnread = false, + onClick = { + onSpecialClick("home") + }, + large = true, + ) + } + + item( + key = "notes" + ) { + val notesChannelId = + RevoltAPI.channelCache.values.firstOrNull { it.channelType == ChannelType.SavedMessages }?.id + + DrawerChannel( + name = stringResource(R.string.channel_notes), + channelType = ChannelType.SavedMessages, + selected = currentDestination == "channel/{channelId}" && currentChannel == notesChannelId, + hasUnread = false, + onClick = { + onChannelClick(notesChannelId ?: return@DrawerChannel) + }, + large = true, + ) + } + + item( + key = "divider" + ) { + Surface( + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + .height(1.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + ) {} + } + + items( + dmAbleChannels.size, + key = { index -> + val channel = dmAbleChannels.getOrNull(index) + channel?.id ?: index + } + ) { + val channel = dmAbleChannels.getOrNull(it) ?: return@items + + val partner = + if (channel.channelType == ChannelType.DirectMessage) RevoltAPI.userCache[ChannelUtils.resolveDMPartner( + channel + )] else null + + DrawerChannel( + name = partner?.username ?: channel.name + ?: stringResource(R.string.unknown), + channelType = channel.channelType ?: ChannelType.TextChannel, + selected = currentDestination == "channel/{channelId}" && currentChannel == channel.id, + hasUnread = channel.lastMessageID?.let { lastMessageID -> + RevoltAPI.unreads.hasUnread( + channel.id!!, + lastMessageID ) + } ?: false, + dmPartnerIcon = partner?.avatar ?: channel.icon, + dmPartnerId = partner?.id, + dmPartnerName = partner?.username, + dmPartnerStatus = presenceFromStatus( + partner?.status?.presence ?: "Offline" + ), + onClick = { + onChannelClick(channel.id ?: return@DrawerChannel) + }, + onLongClick = { + channelContextSheetTarget = channel.id ?: return@DrawerChannel + channelContextSheetShown = true } + ) } } else { val server = RevoltAPI.serverCache[serverId] - Text( - text = server?.name - ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.labelLarge, - fontSize = 24.sp, - modifier = Modifier.padding(16.dp) - ) + item { + Text( + text = server?.name + ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.labelLarge, + fontSize = 24.sp, + modifier = Modifier.padding(16.dp) + ) + } 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, - ) + item { + 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()) + items( + server?.channels?.size ?: 0, + key = { server?.channels?.get(it) ?: "" } ) { - server?.channels?.forEach { channelId -> + server?.channels?.get(it)?.let { channelId -> RevoltAPI.channelCache[channelId]?.let { ch -> DrawerChannel( name = ch.name!!, channelType = ch.channelType!!, - selected = currentChannel == ch.id, + selected = currentDestination == "channel/{channelId}" && currentChannel == ch.id, hasUnread = ch.lastMessageID?.let { lastMessageID -> RevoltAPI.unreads.hasUnread( ch.id!!, @@ -158,7 +235,6 @@ fun RowScope.ChannelList( } ?: true, onClick = { onChannelClick(ch.id ?: return@DrawerChannel) - coroutineScope.launch { drawerState.close() } }, onLongClick = { channelContextSheetTarget = ch.id ?: return@DrawerChannel diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt index 14165424..da1c86db 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt @@ -6,7 +6,13 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme @@ -20,7 +26,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import chat.revolt.api.schemas.AutumnResource import chat.revolt.api.schemas.ChannelType +import chat.revolt.components.generic.GroupIcon +import chat.revolt.components.generic.Presence +import chat.revolt.components.generic.UserAvatar import chat.revolt.components.screens.chat.ChannelIcon @OptIn(ExperimentalFoundationApi::class) @@ -32,6 +42,11 @@ fun DrawerChannel( hasUnread: Boolean, onClick: () -> Unit, onLongClick: () -> Unit = {}, + dmPartnerStatus: Presence? = null, + dmPartnerName: String? = null, + dmPartnerIcon: AutumnResource? = null, + dmPartnerId: String? = null, + large: Boolean = false, ) { val backgroundColor = animateColorAsState( if (selected) MaterialTheme.colorScheme.background @@ -66,7 +81,37 @@ fun DrawerChannel( .padding(vertical = 8.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - ChannelIcon(channelType = channelType, modifier = Modifier.padding(end = 8.dp)) + when (channelType) { + ChannelType.DirectMessage -> UserAvatar( + username = dmPartnerName ?: "", + avatar = dmPartnerIcon, + userId = dmPartnerId ?: "", + presence = dmPartnerStatus, + size = 32.dp, + presenceSize = 16.dp, + modifier = Modifier.padding(end = 8.dp) + ) + + ChannelType.Group -> GroupIcon( + name = name, + icon = dmPartnerIcon, + size = 32.dp, + modifier = Modifier.padding(end = 8.dp) + ) + + else -> ChannelIcon( + channelType = channelType, + modifier = Modifier.then( + if (large) Modifier.padding( + end = 12.dp, + start = 4.dp, + top = 4.dp, + bottom = 4.dp + ) else Modifier.padding(end = 8.dp) + ) + ) + } + Text( text = name, fontWeight = FontWeight.Medium, 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 9c77b251..ae4e2bf0 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -153,6 +153,14 @@ class ChatRouterViewModel @Inject constructor( } } } + + fun navigateToSpecial(destination: String, navController: NavController) { + navController.navigate(destination) { + navController.graph.startDestinationRoute?.let { route -> + popUpTo(route) + } + } + } } @OptIn( @@ -379,11 +387,16 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil ) { ChannelList( serverId = it, - drawerState = drawerState, + currentDestination = navController.currentDestination?.route, currentChannel = viewModel.currentChannel, onChannelClick = { channelId -> viewModel.navigateToChannel(channelId, navController) - } + scope.launch { drawerState.close() } + }, + onSpecialClick = { destination -> + viewModel.navigateToSpecial(destination, navController) + scope.launch { drawerState.close() } + }, ) } } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index 54d4ed2c..11707c4b 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -54,6 +54,7 @@ import chat.revolt.activities.RevoltTweenDp import chat.revolt.activities.RevoltTweenFloat import chat.revolt.activities.RevoltTweenInt import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.ChannelUtils import chat.revolt.api.routes.microservices.autumn.FileArgs import chat.revolt.components.chat.Message import chat.revolt.components.chat.MessageField @@ -390,7 +391,9 @@ fun ChannelScreen( pickFileLauncher.launch(arrayOf("*/*")) }, channelType = channel.channelType, - channelName = channel.name ?: channel.id!!, + channelName = channel.name ?: ChannelUtils.resolveDMName(channel) ?: stringResource( + R.string.unknown + ), forceSendButton = viewModel.attachments.isNotEmpty(), disabled = viewModel.attachments.isNotEmpty() && viewModel.sendingMessage ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10f0eb39..849defc0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,6 +109,8 @@ %1$s\'s avatar + Direct Messages + Direct Message Text Channel Voice Channel