feat: initial direct messages and better group chats

Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
Infi 2023-06-06 00:25:36 +02:00
parent 557a8957f2
commit 1db1ffcb95
10 changed files with 312 additions and 76 deletions

View File

@ -101,6 +101,7 @@ android {
}
lintOptions {
abortOnError false
disable 'MissingTranslation'
}
namespace 'chat.revolt'
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -109,6 +109,8 @@
<string name="avatar_alt">%1$s\'s avatar</string>
<string name="direct_messages">Direct Messages</string>
<string name="channel_dm">Direct Message</string>
<string name="channel_text">Text Channel</string>
<string name="channel_voice">Voice Channel</string>