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 { lintOptions {
abortOnError false abortOnError false
disable 'MissingTranslation'
} }
namespace 'chat.revolt' 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 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource 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.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Send 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -107,7 +120,7 @@ fun MessageField(
Icon( Icon(
Icons.Default.Add, Icons.Default.Add,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
contentDescription = stringResource(id = R.string.unknown), contentDescription = stringResource(id = R.string.add_attachment_alt),
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.size(32.dp) .size(32.dp)
@ -132,7 +145,7 @@ fun MessageField(
Icon( Icon(
Icons.Default.Send, Icons.Default.Send,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
contentDescription = stringResource(id = R.string.unknown), contentDescription = stringResource(id = R.string.send_alt),
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.size(32.dp) .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.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 @Composable
fun UserAvatarWidthPlaceholder( fun UserAvatarWidthPlaceholder(
size: Dp = 40.dp, 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.schemas.Channel import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.ChannelType
@Composable @Composable
fun ChannelHeader( fun ChannelHeader(
@ -64,7 +66,13 @@ fun ChannelHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( 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, fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DrawerState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
@ -19,7 +19,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -30,21 +29,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.generic.presenceFromStatus
import chat.revolt.components.screens.chat.drawer.server.DrawerChannel import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
import chat.revolt.sheets.ChannelContextSheet import chat.revolt.sheets.ChannelContextSheet
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RowScope.ChannelList( fun RowScope.ChannelList(
serverId: String, serverId: String,
drawerState: DrawerState, currentDestination: String?,
currentChannel: String?, currentChannel: String?,
onChannelClick: (String) -> Unit, onChannelClick: (String) -> Unit,
onSpecialClick: (String) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope()
var channelContextSheetShown by remember { mutableStateOf(false) } var channelContextSheetShown by remember { mutableStateOf(false) }
var channelContextSheetTarget by remember { mutableStateOf("") } 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( Surface(
tonalElevation = 1.dp, tonalElevation = 1.dp,
modifier = Modifier modifier = Modifier
@ -74,82 +80,153 @@ fun RowScope.ChannelList(
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Column( LazyColumn(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxSize(),
) { ) {
if (serverId == "home") { if (serverId == "home") {
Column( item(
Modifier key = "header"
.weight(1f)
.verticalScroll(rememberScrollState())
) { ) {
RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.Group } Text(
.forEach { channel -> text = stringResource(R.string.direct_messages),
DrawerChannel( style = MaterialTheme.typography.labelLarge,
name = channel.name fontSize = 24.sp,
?: "GDM #${channel.id}", modifier = Modifier.padding(16.dp)
channelType = ChannelType.Group, )
selected = currentChannel == channel.id, }
hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread( item(
channel.id!!, key = "home"
lastMessageID ) {
) DrawerChannel(
} ?: false, name = stringResource(R.string.home),
onClick = { channelType = ChannelType.TextChannel,
onChannelClick(channel.id ?: return@DrawerChannel) selected = currentDestination == "home",
coroutineScope.launch { drawerState.close() } hasUnread = false,
}, onClick = {
onLongClick = { onSpecialClick("home")
channelContextSheetTarget = channel.id ?: return@DrawerChannel },
channelContextSheetShown = true 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 { } else {
val server = RevoltAPI.serverCache[serverId] val server = RevoltAPI.serverCache[serverId]
Text( item {
text = server?.name Text(
?: stringResource(R.string.unknown), text = server?.name
style = MaterialTheme.typography.labelLarge, ?: stringResource(R.string.unknown),
fontSize = 24.sp, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(16.dp) fontSize = 24.sp,
) modifier = Modifier.padding(16.dp)
)
}
if (server?.channels?.isEmpty() == true) { if (server?.channels?.isEmpty() == true) {
Column( item {
Modifier.weight(1f), Column(
horizontalAlignment = Alignment.CenterHorizontally, Modifier.weight(1f),
verticalArrangement = Arrangement.Center horizontalAlignment = Alignment.CenterHorizontally,
) { verticalArrangement = Arrangement.Center
Text( ) {
text = stringResource(R.string.no_channels_heading), Text(
style = MaterialTheme.typography.labelLarge, text = stringResource(R.string.no_channels_heading),
textAlign = TextAlign.Center, style = MaterialTheme.typography.labelLarge,
fontSize = 24.sp, textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp) fontSize = 24.sp,
) modifier = Modifier.padding(bottom = 16.dp)
Text( )
text = stringResource(R.string.no_channels_body), Text(
style = MaterialTheme.typography.bodyMedium, text = stringResource(R.string.no_channels_body),
textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium,
) textAlign = TextAlign.Center,
)
}
} }
} else { } else {
Column( items(
Modifier server?.channels?.size ?: 0,
.weight(1f) key = { server?.channels?.get(it) ?: "" }
.verticalScroll(rememberScrollState())
) { ) {
server?.channels?.forEach { channelId -> server?.channels?.get(it)?.let { channelId ->
RevoltAPI.channelCache[channelId]?.let { ch -> RevoltAPI.channelCache[channelId]?.let { ch ->
DrawerChannel( DrawerChannel(
name = ch.name!!, name = ch.name!!,
channelType = ch.channelType!!, channelType = ch.channelType!!,
selected = currentChannel == ch.id, selected = currentDestination == "channel/{channelId}" && currentChannel == ch.id,
hasUnread = ch.lastMessageID?.let { lastMessageID -> hasUnread = ch.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread( RevoltAPI.unreads.hasUnread(
ch.id!!, ch.id!!,
@ -158,7 +235,6 @@ fun RowScope.ChannelList(
} ?: true, } ?: true,
onClick = { onClick = {
onChannelClick(ch.id ?: return@DrawerChannel) onChannelClick(ch.id ?: return@DrawerChannel)
coroutineScope.launch { drawerState.close() }
}, },
onLongClick = { onLongClick = {
channelContextSheetTarget = ch.id ?: return@DrawerChannel 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable 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.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.schemas.ChannelType 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 import chat.revolt.components.screens.chat.ChannelIcon
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -32,6 +42,11 @@ fun DrawerChannel(
hasUnread: Boolean, hasUnread: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
dmPartnerStatus: Presence? = null,
dmPartnerName: String? = null,
dmPartnerIcon: AutumnResource? = null,
dmPartnerId: String? = null,
large: Boolean = false,
) { ) {
val backgroundColor = animateColorAsState( val backgroundColor = animateColorAsState(
if (selected) MaterialTheme.colorScheme.background if (selected) MaterialTheme.colorScheme.background
@ -66,7 +81,37 @@ fun DrawerChannel(
.padding(vertical = 8.dp, horizontal = 16.dp), .padding(vertical = 8.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically 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(
text = name, text = name,
fontWeight = FontWeight.Medium, 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( @OptIn(
@ -379,11 +387,16 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
) { ) {
ChannelList( ChannelList(
serverId = it, serverId = it,
drawerState = drawerState, currentDestination = navController.currentDestination?.route,
currentChannel = viewModel.currentChannel, currentChannel = viewModel.currentChannel,
onChannelClick = { channelId -> onChannelClick = { channelId ->
viewModel.navigateToChannel(channelId, navController) 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.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.routes.microservices.autumn.FileArgs import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.components.chat.Message import chat.revolt.components.chat.Message
import chat.revolt.components.chat.MessageField import chat.revolt.components.chat.MessageField
@ -390,7 +391,9 @@ fun ChannelScreen(
pickFileLauncher.launch(arrayOf("*/*")) pickFileLauncher.launch(arrayOf("*/*"))
}, },
channelType = channel.channelType, channelType = channel.channelType,
channelName = channel.name ?: channel.id!!, channelName = channel.name ?: ChannelUtils.resolveDMName(channel) ?: stringResource(
R.string.unknown
),
forceSendButton = viewModel.attachments.isNotEmpty(), forceSendButton = viewModel.attachments.isNotEmpty(),
disabled = viewModel.attachments.isNotEmpty() && viewModel.sendingMessage disabled = viewModel.attachments.isNotEmpty() && viewModel.sendingMessage
) )

View File

@ -109,6 +109,8 @@
<string name="avatar_alt">%1$s\'s avatar</string> <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_dm">Direct Message</string>
<string name="channel_text">Text Channel</string> <string name="channel_text">Text Channel</string>
<string name="channel_voice">Voice Channel</string> <string name="channel_voice">Voice Channel</string>