feat: save last channel, show when no channel/s, less NPEs

This commit is contained in:
Infi 2023-03-20 01:26:42 +01:00
parent 7bf4d3aadf
commit 40a9cdf344
9 changed files with 235 additions and 72 deletions

View File

@ -19,7 +19,7 @@ object ULID {
0x59.toChar(), 0x5a.toChar() 0x59.toChar(), 0x5a.toChar()
) )
fun makeSpecial(timestamp: Long, entropy: ByteArray): String { fun makeSpecial(timestamp: Long, entropy: ByteArray = fetchEntropy()): String {
if (timestamp < minTimestamp || timestamp > maxTimestamp) { if (timestamp < minTimestamp || timestamp > maxTimestamp) {
throw IllegalArgumentException("timestamp out of range: $timestamp") throw IllegalArgumentException("timestamp out of range: $timestamp")
} }

View File

@ -4,11 +4,17 @@ import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson import chat.revolt.api.RevoltJson
import chat.revolt.api.internals.ULID import chat.revolt.api.internals.ULID
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.MessagesInChannel import chat.revolt.api.schemas.MessagesInChannel
import io.ktor.client.request.* import io.ktor.client.request.get
import io.ktor.client.statement.* import io.ktor.client.request.parameter
import io.ktor.http.* import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
suspend fun fetchMessagesFromChannel( suspend fun fetchMessagesFromChannel(
@ -95,4 +101,16 @@ suspend fun ackChannel(channelId: String, messageId: String = ULID.makeNext()) {
RevoltHttp.put("/channels/$channelId/ack/$messageId") { RevoltHttp.put("/channels/$channelId/ack/$messageId") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
} }
}
suspend fun fetchSingleChannel(channelId: String): Channel {
val response = RevoltHttp.get("/channels/$channelId") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
}
.bodyAsText()
return RevoltJson.decodeFromString(
Channel.serializer(),
response
)
} }

View File

@ -5,8 +5,8 @@ import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.User import chat.revolt.api.schemas.User
import io.ktor.client.request.* import io.ktor.client.request.get
import io.ktor.client.statement.* import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
suspend fun fetchSelf(): User { suspend fun fetchSelf(): User {
@ -50,7 +50,9 @@ suspend fun fetchUser(id: String): User {
val user = RevoltJson.decodeFromString(User.serializer(), response) val user = RevoltJson.decodeFromString(User.serializer(), response)
RevoltAPI.userCache[user.id!!] = user user.id?.let {
RevoltAPI.userCache[it] = user
}
return user return user
} }

View File

@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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
@ -95,7 +96,7 @@ fun Message(
if (message.tail == false) { if (message.tail == false) {
UserAvatar( UserAvatar(
username = author.username ?: "", username = author.username ?: "",
userId = author.id!!, userId = author.id ?: message.id ?: ULID.makeSpecial(0),
avatar = author.avatar, avatar = author.avatar,
rawUrl = message.masquerade?.avatar?.let { asJanuaryProxyUrl(it) } rawUrl = message.masquerade?.avatar?.let { asJanuaryProxyUrl(it) }
) )
@ -107,7 +108,9 @@ fun Message(
if (message.tail == false) { if (message.tail == false) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = message.masquerade?.name ?: author.username ?: "", text = message.masquerade?.name
?: author.username
?: stringResource(id = R.string.unknown),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (message.masquerade?.colour != null) { color = if (message.masquerade?.colour != null) {
WebCompat.parseColour(message.masquerade.colour) WebCompat.parseColour(message.masquerade.colour)

View File

@ -1,7 +1,9 @@
package chat.revolt.components.screens.chat.drawer.channel 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.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -10,15 +12,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
@ -29,17 +30,19 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun RowScope.ChannelList( fun RowScope.ChannelList(
serverId: String, serverId: String,
navController: NavController, drawerState: DoubleDrawerState,
drawerState: DoubleDrawerState currentChannel: String?,
onChannelClick: (String) -> Unit,
onChannelLongClick: (String) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val navBackStackEntry by navController.currentBackStackEntryAsState()
Surface( Surface(
tonalElevation = 1.dp, tonalElevation = 1.dp,
modifier = Modifier modifier = Modifier
.padding(start = 4.dp, top = 8.dp, bottom = 8.dp) .padding(start = 4.dp, top = 8.dp, bottom = 8.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.fillMaxWidth(),
) { ) {
Column( Column(
Modifier Modifier
@ -57,9 +60,7 @@ fun RowScope.ChannelList(
name = channel.name name = channel.name
?: "GDM #${channel.id}", ?: "GDM #${channel.id}",
channelType = ChannelType.Group, channelType = ChannelType.Group,
selected = (channel.id == navBackStackEntry?.arguments?.getString( selected = currentChannel == channel.id,
"channelId"
)),
hasUnread = channel.lastMessageID?.let { lastMessageID -> hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread( RevoltAPI.unreads.hasUnread(
channel.id!!, channel.id!!,
@ -67,15 +68,11 @@ fun RowScope.ChannelList(
) )
} ?: false, } ?: false,
onClick = { onClick = {
navController.navigate("channel/${channel.id}") { onChannelClick(channel.id ?: return@DrawerChannel)
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route)
}
}
coroutineScope.launch { drawerState.focusCenter() } coroutineScope.launch { drawerState.focusCenter() }
}, },
onLongClick = { onLongClick = {
navController.navigate("channel/${channel.id}/info") onChannelLongClick(channel.id ?: return@DrawerChannel)
} }
) )
} }
@ -91,37 +88,52 @@ fun RowScope.ChannelList(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
Column( if (server?.channels?.isEmpty() == true) {
Modifier Column(
.weight(1f) Modifier.weight(1f),
.verticalScroll(rememberScrollState()) horizontalAlignment = Alignment.CenterHorizontally,
) { verticalArrangement = Arrangement.Center
server?.channels?.forEach { channelId -> ) {
RevoltAPI.channelCache[channelId]?.let { ch -> Text(
DrawerChannel( text = stringResource(R.string.no_channels_heading),
name = ch.name!!, style = MaterialTheme.typography.labelLarge,
channelType = ch.channelType!!, textAlign = TextAlign.Center,
selected = navBackStackEntry?.arguments?.getString( fontSize = 24.sp,
"channelId" modifier = Modifier.padding(bottom = 16.dp)
) == ch.id, )
hasUnread = ch.lastMessageID?.let { lastMessageID -> Text(
RevoltAPI.unreads.hasUnread( text = stringResource(R.string.no_channels_body),
ch.id!!, style = MaterialTheme.typography.bodyMedium,
lastMessageID textAlign = TextAlign.Center,
) )
} ?: true, }
onClick = { } else {
coroutineScope.launch { drawerState.focusCenter() } Column(
navController.navigate("channel/${ch.id}") { Modifier
navController.graph.startDestinationRoute?.let { route -> .weight(1f)
popUpTo(route) .verticalScroll(rememberScrollState())
} ) {
server?.channels?.forEach { channelId ->
RevoltAPI.channelCache[channelId]?.let { ch ->
DrawerChannel(
name = ch.name!!,
channelType = ch.channelType!!,
selected = currentChannel == ch.id,
hasUnread = ch.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread(
ch.id!!,
lastMessageID
)
} ?: true,
onClick = {
onChannelClick(ch.id ?: return@DrawerChannel)
coroutineScope.launch { drawerState.focusCenter() }
},
onLongClick = {
onChannelLongClick(ch.id ?: return@DrawerChannel)
} }
}, )
onLongClick = { }
navController.navigate("channel/${ch.id}/menu")
}
)
} }
} }
} }

View File

@ -34,8 +34,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -55,6 +56,7 @@ import chat.revolt.components.screens.chat.drawer.server.DrawerServer
import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon
import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator
import chat.revolt.components.screens.chat.rememberDoubleDrawerState import chat.revolt.components.screens.chat.rememberDoubleDrawerState
import chat.revolt.persistence.KVStorage
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
import chat.revolt.screens.chat.sheets.ChannelContextSheet import chat.revolt.screens.chat.sheets.ChannelContextSheet
import chat.revolt.screens.chat.sheets.ChannelInfoSheet import chat.revolt.screens.chat.sheets.ChannelInfoSheet
@ -62,35 +64,80 @@ import chat.revolt.screens.chat.sheets.MessageContextSheet
import chat.revolt.screens.chat.sheets.StatusSheet import chat.revolt.screens.chat.sheets.StatusSheet
import chat.revolt.screens.chat.views.ChannelScreen import chat.revolt.screens.chat.views.ChannelScreen
import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.screens.chat.views.HomeScreen
import chat.revolt.screens.chat.views.NoCurrentChannelScreen
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.google.accompanist.navigation.material.ModalBottomSheetLayout import com.google.accompanist.navigation.material.ModalBottomSheetLayout
import com.google.accompanist.navigation.material.bottomSheet import com.google.accompanist.navigation.material.bottomSheet
import com.google.accompanist.navigation.material.rememberBottomSheetNavigator import com.google.accompanist.navigation.material.rememberBottomSheetNavigator
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
class ChatRouterViewModel : ViewModel() { @HiltViewModel
class ChatRouterViewModel @Inject constructor(
private val kvStorage: KVStorage
) : ViewModel() {
private var _currentServer = mutableStateOf("home") private var _currentServer = mutableStateOf("home")
val currentServer: String val currentServer: String
get() = _currentServer.value get() = _currentServer.value
fun setCurrentServer(serverId: String) { private var _currentChannel = mutableStateOf<String?>(null)
val currentChannel: String?
get() = _currentChannel.value
init {
viewModelScope.launch {
_currentServer.value = kvStorage.get("currentServer") ?: "home"
_currentChannel.value = kvStorage.get("currentChannel")
}
}
private fun setCurrentServer(serverId: String, save: Boolean = true) {
_currentServer.value = serverId _currentServer.value = serverId
if (save) viewModelScope.launch {
kvStorage.set("currentServer", serverId)
}
}
private fun setCurrentChannel(channelId: String) {
_currentChannel.value = channelId
viewModelScope.launch {
kvStorage.set("currentChannel", channelId)
}
} }
fun navigateToServer(serverId: String, navController: NavController) { fun navigateToServer(serverId: String, navController: NavController) {
setCurrentServer(serverId)
if (serverId == "home") { if (serverId == "home") {
navController.navigate("home") { navController.navigate("home") {
navController.graph.startDestinationRoute?.let { route -> navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) popUpTo(route)
} }
} }
setCurrentServer("home")
return return
} }
val channelId = RevoltAPI.serverCache[serverId]?.channels?.firstOrNull() val channelId = RevoltAPI.serverCache[serverId]?.channels?.firstOrNull()
setCurrentServer(serverId, channelId != null)
if (channelId != null) {
navigateToChannel(channelId, navController)
} else {
navController.navigate("no_current_channel") {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route)
}
}
}
}
fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) {
if (!pure) setCurrentChannel(channelId)
navController.navigate("channel/$channelId") { navController.navigate("channel/$channelId") {
navController.graph.startDestinationRoute?.let { route -> navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) popUpTo(route)
@ -101,7 +148,7 @@ class ChatRouterViewModel : ViewModel() {
@OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = viewModel()) { fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hiltViewModel()) {
val drawerState = rememberDoubleDrawerState() val drawerState = rememberDoubleDrawerState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@ -120,6 +167,16 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
} }
} }
LaunchedEffect(viewModel.currentChannel) {
snapshotFlow { viewModel.currentChannel }
.distinctUntilChanged()
.collect { channelId ->
if (channelId != null) {
viewModel.navigateToChannel(channelId, navController, pure = true)
}
}
}
ModalBottomSheetLayout( ModalBottomSheetLayout(
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
sheetBackgroundColor = MaterialTheme.colorScheme.surface, sheetBackgroundColor = MaterialTheme.colorScheme.surface,
@ -207,8 +264,14 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
Crossfade(targetState = viewModel.currentServer) { Crossfade(targetState = viewModel.currentServer) {
ChannelList( ChannelList(
serverId = it, serverId = it,
navController = navController, drawerState = drawerState,
drawerState = drawerState currentChannel = viewModel.currentChannel,
onChannelClick = { channelId ->
viewModel.navigateToChannel(channelId, navController)
},
onChannelLongClick = { channelId ->
navController.navigate("channel/$channelId/info")
},
) )
} }
} }
@ -239,6 +302,9 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
) )
} }
} }
composable("no_current_channel") {
NoCurrentChannelScreen()
}
bottomSheet("channel/{channelId}/info") { backStackEntry -> bottomSheet("channel/{channelId}/info") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") val channelId = backStackEntry.arguments?.getString("channelId")

View File

@ -43,6 +43,7 @@ import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.routes.channel.SendMessageReply import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.api.routes.channel.ackChannel import chat.revolt.api.routes.channel.ackChannel
import chat.revolt.api.routes.channel.fetchMessagesFromChannel import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.api.routes.channel.fetchSingleChannel
import chat.revolt.api.routes.channel.sendMessage import chat.revolt.api.routes.channel.sendMessage
import chat.revolt.api.routes.microservices.autumn.FileArgs import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
@ -81,6 +82,8 @@ class ChannelScreenViewModel : ViewModel() {
val channel: Channel? val channel: Channel?
get() = _channel get() = _channel
private var _uiCallbackRegistered by mutableStateOf(false)
private var _channelCallback = mutableStateOf<RealtimeSocket.ChannelCallback?>(null) private var _channelCallback = mutableStateOf<RealtimeSocket.ChannelCallback?>(null)
private val channelCallback: RealtimeSocket.ChannelCallback? private val channelCallback: RealtimeSocket.ChannelCallback?
get() = _channelCallback.value get() = _channelCallback.value
@ -222,8 +225,16 @@ class ChannelScreenViewModel : ViewModel() {
_channelCallback.value = ChannelScreenCallback() _channelCallback.value = ChannelScreenCallback()
RealtimeSocket.registerChannelCallback(channel!!.id!!, channelCallback!!) RealtimeSocket.registerChannelCallback(channel!!.id!!, channelCallback!!)
_uiCallbackReceiver.value = UiCallbackReceiver() if (!_uiCallbackRegistered) {
UiCallbacks.registerReceiver(uiCallbackReceiver!!) _uiCallbackReceiver.value = UiCallbackReceiver()
UiCallbacks.registerReceiver(uiCallbackReceiver!!)
_uiCallbackRegistered = true
} else {
Log.d(
"ChannelScreenViewModel",
"UI Callbacks already registered but trying to register again. Ignoring but this is a bug."
)
}
} }
fun fetchMessages() { fun fetchMessages() {
@ -300,16 +311,22 @@ class ChannelScreenViewModel : ViewModel() {
} }
fun fetchChannel(id: String) { fun fetchChannel(id: String) {
if (id in RevoltAPI.channelCache) { viewModelScope.launch {
_channel = RevoltAPI.channelCache[id] if (id !in RevoltAPI.channelCache) {
} else { val channel = fetchSingleChannel(id)
Log.e("ChannelScreen", "Channel $id not in cache, for now this is fatal!") // FIXME _channel = channel
} RevoltAPI.channelCache[id] = channel
} else {
_channel = RevoltAPI.channelCache[id]
}
registerCallbacks() registerCallbacks()
if (channel?.lastMessageID != null) { if (_channel?.lastMessageID != null) {
ackNewest() ackNewest()
} else {
Log.d("ChannelScreen", "No last message ID, not acking.")
}
} }
} }

View File

@ -0,0 +1,40 @@
package chat.revolt.screens.chat.views
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
@Composable
fun NoCurrentChannelScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(64.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.no_active_channel),
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
fontSize = 24.sp,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = stringResource(R.string.no_active_channel_body),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}

View File

@ -89,6 +89,11 @@
<string name="server_plus_alt">Add server</string> <string name="server_plus_alt">Add server</string>
<string name="no_channels_heading">Bit awkward.</string>
<string name="no_channels_body">There aren\'t any channels in this server. Not even a welcome channel. How rude.</string>
<string name="no_active_channel">You\'re not in a channel right now.</string>
<string name="no_active_channel_body">Select a server from the left to get started. If you\'re feeling adventurous, you can create a new server.</string>
<string name="avatar_alt">%1$s\'s avatar</string> <string name="avatar_alt">%1$s\'s avatar</string>
<string name="channel_dm">Direct Message</string> <string name="channel_dm">Direct Message</string>