feat: friends screen

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-30 02:18:26 +01:00
parent ac5148e82c
commit 55e8d184f4
11 changed files with 506 additions and 68 deletions

View File

@ -0,0 +1,36 @@
package chat.revolt.api.internals
import chat.revolt.api.RevoltAPI
import chat.revolt.api.schemas.User
object FriendRequests {
fun getIncoming(): List<User> {
return RevoltAPI.userCache.values.filter { user ->
user.relationship == "Incoming"
}
}
fun getIncomingCount(): Int {
return getIncoming().size
}
fun getOutgoing(): List<User> {
return RevoltAPI.userCache.values.filter { user ->
user.relationship == "Outgoing"
}
}
fun getOutgoingCount(): Int {
return getOutgoing().size
}
fun getBlocked(): List<User> {
return RevoltAPI.userCache.values.filter { user ->
user.relationship == "Blocked"
}
}
fun getBlockedCount(): Int {
return getBlocked().size
}
}

View File

@ -23,6 +23,7 @@ import chat.revolt.api.realtime.frames.receivable.ServerMemberJoinFrame
import chat.revolt.api.realtime.frames.receivable.ServerMemberLeaveFrame
import chat.revolt.api.realtime.frames.receivable.ServerMemberUpdateFrame
import chat.revolt.api.realtime.frames.receivable.ServerUpdateFrame
import chat.revolt.api.realtime.frames.receivable.UserRelationshipFrame
import chat.revolt.api.realtime.frames.receivable.UserUpdateFrame
import chat.revolt.api.realtime.frames.sendable.AuthorizationFrame
import chat.revolt.api.realtime.frames.sendable.PingFrame
@ -262,6 +263,27 @@ object RealtimeSocket {
existing.mergeWithPartial(userUpdateFrame.data)
}
"UserRelationship" -> {
val userRelationshipFrame =
RevoltJson.decodeFromString(UserRelationshipFrame.serializer(), rawFrame)
val existing = RevoltAPI.userCache[userRelationshipFrame.user.id]
if (existing == null && userRelationshipFrame.user.id != null) {
RevoltAPI.userCache[userRelationshipFrame.user.id] =
userRelationshipFrame.user.copy(
relationship = userRelationshipFrame.status
)
} else if (existing != null && userRelationshipFrame.user.id != null) {
val merged = existing.mergeWithPartial(userRelationshipFrame.user).copy(
relationship = userRelationshipFrame.status
)
RevoltAPI.userCache[userRelationshipFrame.user.id] = merged
} else {
Log.w("RealtimeSocket", "Invalid UserRelationship frame: $rawFrame")
}
}
"ChannelUpdate" -> {
val channelUpdateFrame =
RevoltJson.decodeFromString(ChannelUpdateFrame.serializer(), rawFrame)

View File

@ -7,8 +7,8 @@ import chat.revolt.api.RevoltJson
import io.ktor.client.request.delete
import io.ktor.client.request.put
import io.ktor.client.statement.bodyAsText
import kotlin.collections.set
import kotlinx.serialization.SerializationException
import kotlin.collections.set
suspend fun blockUser(userId: String) {
val response = RevoltHttp.put("/users/$userId/block")
@ -39,3 +39,18 @@ suspend fun unblockUser(userId: String) {
val user = RevoltAPI.userCache[userId] ?: return
RevoltAPI.userCache[userId] = user.copy(relationship = "None")
}
suspend fun unfriendUser(userId: String) {
val response = RevoltHttp.delete("/users/$userId/friend")
.bodyAsText()
try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), response)
throw Error(error.type)
} catch (e: SerializationException) {
// Not an error
}
val user = RevoltAPI.userCache[userId] ?: return
RevoltAPI.userCache[userId] = user.copy(relationship = "None")
}

View File

@ -27,6 +27,7 @@ fun PageHeader(
modifier: Modifier = Modifier,
showBackButton: Boolean = false,
onBackButtonClicked: () -> Unit = {},
startButtons: @Composable () -> Unit = {},
additionalButtons: @Composable () -> Unit = {},
maxLines: Int = Int.MAX_VALUE
) {
@ -42,6 +43,7 @@ fun PageHeader(
)
}
}
startButtons()
Text(
text = text,
maxLines = maxLines,

View File

@ -68,6 +68,7 @@ import chat.revolt.api.schemas.User
import chat.revolt.api.schemas.has
import chat.revolt.components.generic.presenceFromStatus
import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType
import chat.revolt.sheets.ChannelContextSheet
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
@ -90,7 +91,7 @@ fun RowScope.ChannelList(
val enableSmallBanner by remember {
derivedStateOf {
lazyListState.firstVisibleItemScrollOffset > 40 ||
lazyListState.firstVisibleItemIndex > 0
lazyListState.firstVisibleItemIndex > 0
}
}
@ -186,7 +187,7 @@ fun RowScope.ChannelList(
) {
DrawerChannel(
name = stringResource(R.string.home),
channelType = ChannelType.TextChannel,
iconType = DrawerChannelIconType.Painter(painterResource(R.drawable.ic_home_24dp)),
selected = currentDestination == "home",
hasUnread = false,
onClick = {
@ -196,6 +197,21 @@ fun RowScope.ChannelList(
)
}
item(
key = "friends"
) {
DrawerChannel(
name = stringResource(R.string.friends),
iconType = DrawerChannelIconType.Painter(painterResource(R.drawable.ic_human_greeting_variant_24dp)),
selected = currentDestination == "friends",
hasUnread = false,
onClick = {
onSpecialClick("friends")
},
large = true
)
}
item(
key = "notes"
) {
@ -204,7 +220,7 @@ fun RowScope.ChannelList(
DrawerChannel(
name = stringResource(R.string.channel_notes),
channelType = ChannelType.SavedMessages,
iconType = DrawerChannelIconType.Channel(ChannelType.SavedMessages),
selected = currentDestination == "channel/{channelId}" && currentChannel == notesChannelId,
hasUnread = false,
onClick = {
@ -248,8 +264,10 @@ fun RowScope.ChannelList(
DrawerChannel(
name = partner?.let { p -> User.resolveDefaultName(p) } ?: channel.name
?: stringResource(R.string.unknown),
channelType = channel.channelType ?: ChannelType.TextChannel,
?: stringResource(R.string.unknown),
iconType = DrawerChannelIconType.Channel(
channel.channelType ?: ChannelType.TextChannel
),
selected = currentDestination == "channel/{channelId}" && currentChannel == channel.id,
hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread(
@ -408,9 +426,9 @@ fun RowScope.ChannelList(
Text(
text = (
server?.name
?: stringResource(R.string.unknown)
),
server?.name
?: stringResource(R.string.unknown)
),
style = MaterialTheme.typography.labelLarge,
color = if (server?.banner != null) {
bannerTextColour
@ -513,7 +531,9 @@ fun RowScope.ChannelList(
name = partner?.let { p -> User.resolveDefaultName(p) }
?: channel.name
?: stringResource(R.string.unknown),
channelType = channel.channelType ?: ChannelType.TextChannel,
iconType = DrawerChannelIconType.Channel(
channel.channelType ?: ChannelType.TextChannel
),
selected = currentDestination == "channel/{channelId}" && currentChannel == channel.id,
hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread(

View File

@ -14,6 +14,7 @@ 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.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -33,10 +34,16 @@ import chat.revolt.components.generic.Presence
import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.screens.chat.ChannelIcon
sealed class DrawerChannelIconType {
data class Channel(val type: ChannelType) : DrawerChannelIconType()
data class Painter(val painter: androidx.compose.ui.graphics.painter.Painter) :
DrawerChannelIconType()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DrawerChannel(
channelType: ChannelType,
iconType: DrawerChannelIconType,
name: String,
selected: Boolean,
hasUnread: Boolean,
@ -84,39 +91,55 @@ fun DrawerChannel(
.padding(vertical = 8.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
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)
)
when (iconType) {
is DrawerChannelIconType.Channel -> {
when (val channelType = iconType.type) {
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)
)
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 -> 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)
}
)
} else {
Modifier.padding(end = 8.dp)
}
)
}
}
is DrawerChannelIconType.Painter -> {
Icon(
painter = iconType.painter,
contentDescription = null,
tint = LocalContentColor.current,
modifier = Modifier
.padding(end = 8.dp)
.size(32.dp)
.padding(4.dp)
)
)
}
}
Text(

View File

@ -94,6 +94,7 @@ import chat.revolt.internals.Changelogs
import chat.revolt.ndk.Pipebomb
import chat.revolt.persistence.KVStorage
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
import chat.revolt.screens.chat.views.FriendsScreen
import chat.revolt.screens.chat.views.HomeScreen
import chat.revolt.screens.chat.views.NoCurrentChannelScreen
import chat.revolt.screens.chat.views.channel.ChannelScreen
@ -112,9 +113,9 @@ import com.airbnb.lottie.compose.rememberLottieComposition
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.sentry.Sentry
import javax.inject.Inject
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
@SuppressLint("StaticFieldLeak")
@ -304,7 +305,7 @@ fun ChatRouterScreen(
var useTabletAwareUI by remember { mutableStateOf(false) }
val drawerBackHandler = remember {
val toggleDrawerLda = remember {
{
scope.launch {
if (drawerState.isOpen) {
@ -376,7 +377,7 @@ fun ChatRouterScreen(
.distinctUntilChanged()
.collect { sizeClass ->
useTabletAwareUI = sizeClass.widthSizeClass == WindowWidthSizeClass.Expanded &&
sizeClass.heightSizeClass != WindowHeightSizeClass.Compact
sizeClass.heightSizeClass != WindowHeightSizeClass.Compact
}
}
@ -680,8 +681,8 @@ fun ChatRouterScreen(
navController = navController,
topNav = topNav,
useDrawer = false,
drawerBackHandler = {
drawerBackHandler()
toggleDrawer = {
toggleDrawerLda()
},
onShowUserContextSheet = { target, server ->
userContextSheetTarget = target
@ -720,8 +721,8 @@ fun ChatRouterScreen(
navController = navController,
topNav = topNav,
useDrawer = true,
drawerBackHandler = {
drawerBackHandler()
toggleDrawer = {
toggleDrawerLda()
},
drawerState = drawerState,
onShowUserContextSheet = { target, server ->
@ -857,21 +858,21 @@ fun Sidebar(
// - Add the servers that aren't in the ordering to the end of the list.
// - Sort the servers that aren't in the ordering by their ID (creation order).
(
(
RevoltAPI.serverCache.values.filter {
SyncedSettings.ordering.servers.contains(
it.id
)
}
.sortedBy { SyncedSettings.ordering.servers.indexOf(it.id) }
) + (
RevoltAPI.serverCache.values.filter {
!SyncedSettings.ordering.servers.contains(
it.id
)
}.sortedBy { it.id }
(
RevoltAPI.serverCache.values.filter {
SyncedSettings.ordering.servers.contains(
it.id
)
}
.sortedBy { SyncedSettings.ordering.servers.indexOf(it.id) }
) + (
RevoltAPI.serverCache.values.filter {
!SyncedSettings.ordering.servers.contains(
it.id
)
}.sortedBy { it.id }
)
)
)
.forEach { server ->
if (server.id == null || server.name == null) return@forEach
@ -935,7 +936,7 @@ fun ChannelNavigator(
navController: NavHostController,
topNav: NavController,
useDrawer: Boolean,
drawerBackHandler: () -> Unit,
toggleDrawer: () -> Unit,
drawerState: DrawerState? = null,
onShowUserContextSheet: (String, String?) -> Unit
) {
@ -945,14 +946,28 @@ fun ChannelNavigator(
NavHost(navController = navController, startDestination = "home") {
composable("home") {
BackHandler(enabled = useDrawer) {
drawerBackHandler()
toggleDrawer()
}
HomeScreen(navController = topNav)
HomeScreen(
navController = topNav,
useDrawer = useDrawer,
onDrawerClicked = toggleDrawer,
)
}
composable("friends") {
BackHandler(enabled = useDrawer) {
toggleDrawer()
}
FriendsScreen(
useDrawer = useDrawer,
onDrawerClicked = toggleDrawer,
)
}
composable("channel/{channelId}") { backStackEntry ->
BackHandler(enabled = useDrawer) {
drawerBackHandler()
toggleDrawer()
}
val channelId = backStackEntry.arguments?.getString("channelId")
@ -979,7 +994,7 @@ fun ChannelNavigator(
composable("no_current_channel") {
BackHandler(enabled = useDrawer) {
drawerBackHandler()
toggleDrawer()
}
NoCurrentChannelScreen()

View File

@ -0,0 +1,276 @@
package chat.revolt.screens.chat.views
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
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.FriendRequests
import chat.revolt.api.routes.user.unfriendUser
import chat.revolt.api.schemas.User
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.generic.PageHeader
import chat.revolt.components.generic.SheetClickable
import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.generic.presenceFromStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun FriendsOptionsSheet(onDenyAll: () -> Unit) {
SheetClickable(
icon = { modifier ->
Icon(
modifier = modifier,
painter = painterResource(R.drawable.ic_account_cancel_24dp),
contentDescription = null
)
},
label = { style ->
Text(
text = stringResource(R.string.friends_deny_all_incoming),
style = style
)
},
onClick = { onDenyAll() }
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun FriendsScreen(useDrawer: Boolean, onDrawerClicked: () -> Unit) {
var optionsSheetShown by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (optionsSheetShown) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = {
optionsSheetShown = false
},
sheetState = sheetState
) {
FriendsOptionsSheet(
onDenyAll = {
scope.launch {
sheetState.hide()
}
with(Dispatchers.IO) {
scope.launch {
FriendRequests.getIncoming()
.forEach { it.id?.let { id -> unfriendUser(id) } }
}
}
}
)
}
}
Column {
PageHeader(
text = "Friends",
startButtons = {
if (useDrawer) {
IconButton(onClick = onDrawerClicked) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.menu)
)
}
}
},
additionalButtons = {
IconButton(onClick = {
optionsSheetShown = true
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.menu)
)
}
}
)
LazyColumn {
stickyHeader(key = "incoming") {
Text(
text = AnnotatedString.Builder().apply {
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
append(stringResource(id = R.string.friends_incoming_requests))
pop()
pushStyle(
SpanStyle(
fontWeight = FontWeight.Medium,
fontSize = LocalTextStyle.current.fontSize * 0.8,
color = LocalContentColor.current.copy(alpha = 0.6f)
)
)
append("${FriendRequests.getIncoming().size}")
pop()
}.toAnnotatedString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(10.dp)
)
}
items(FriendRequests.getIncoming().size) {
val item = FriendRequests.getIncoming()[it]
UserItem(item, onClick = {
scope.launch {
item.id?.let { userId ->
ActionChannel.send(Action.OpenUserSheet(userId, null))
}
}
})
}
stickyHeader(key = "outgoing") {
Text(
text = AnnotatedString.Builder().apply {
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
append(stringResource(id = R.string.friends_outgoing_requests))
pop()
pushStyle(
SpanStyle(
fontWeight = FontWeight.Medium,
fontSize = LocalTextStyle.current.fontSize * 0.8,
color = LocalContentColor.current.copy(alpha = 0.6f)
)
)
append("${FriendRequests.getOutgoing().size}")
pop()
}.toAnnotatedString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(10.dp)
)
}
items(FriendRequests.getOutgoing().size) {
val item = FriendRequests.getOutgoing()[it]
UserItem(item, onClick = {
scope.launch {
item.id?.let { userId ->
ActionChannel.send(Action.OpenUserSheet(userId, null))
}
}
})
}
stickyHeader(key = "blocked") {
Text(
text = AnnotatedString.Builder().apply {
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
append(stringResource(id = R.string.friends_blocked))
pop()
pushStyle(
SpanStyle(
fontWeight = FontWeight.Medium,
fontSize = LocalTextStyle.current.fontSize * 0.8,
color = LocalContentColor.current.copy(alpha = 0.6f)
)
)
append("${FriendRequests.getBlocked().size}")
pop()
}.toAnnotatedString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(10.dp)
)
}
items(FriendRequests.getBlocked().size) {
val item = FriendRequests.getBlocked()[it]
UserItem(item, onClick = {
scope.launch {
item.id?.let { userId ->
ActionChannel.send(Action.OpenUserSheet(userId, null))
}
}
})
}
}
}
}
@Composable
fun UserItem(user: User, onClick: () -> Unit = {}) {
Row(
modifier = Modifier
.clickable {
onClick()
}
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
username = user.displayName
?: user.username
?: user.id!!,
avatar = user.avatar,
userId = user.id!!,
presence = presenceFromStatus(
user.status?.presence,
user.online ?: false
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = user.displayName
?: user.username
?: user.id,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

View File

@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -33,7 +35,7 @@ import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.home.LinkOnHome
@Composable
fun HomeScreen(navController: NavController) {
fun HomeScreen(navController: NavController, useDrawer: Boolean, onDrawerClicked: () -> Unit) {
val context = LocalContext.current
val catTransition = rememberInfiniteTransition(label = "cat")
@ -50,7 +52,19 @@ fun HomeScreen(navController: NavController) {
Column(
modifier = Modifier.safeDrawingPadding()
) {
PageHeader(text = stringResource(id = R.string.home))
PageHeader(
text = stringResource(id = R.string.home),
startButtons = {
if (useDrawer) {
IconButton(onClick = onDrawerClicked) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.menu)
)
}
}
}
)
Box(
modifier = Modifier
.weight(1f)

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" />
</vector>

View File

@ -123,6 +123,12 @@
<string name="home_join_jenvolt">Join Jenvolt</string>
<string name="home_join_jenvolt_description">Jenvolt is the developer-run space for all things Android app and more. Support, feedback go here. Maybe you will get to try out new features! 👀</string>
<string name="friends">Friends</string>
<string name="friends_incoming_requests">Incoming Requests</string>
<string name="friends_outgoing_requests">Outgoing Requests</string>
<string name="friends_blocked">Blocked</string>
<string name="friends_deny_all_incoming">Clear all incoming requests</string>
<string name="server_plus_alt">Add server</string>
<string name="no_channels_heading">Bit awkward.</string>