feat: connection handling

- also adds an internet check on launch
This commit is contained in:
Infi 2023-01-03 05:37:42 +01:00
parent 8f068edeec
commit d93b9f1bcb
8 changed files with 361 additions and 156 deletions

View File

@ -4,6 +4,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf import chat.revolt.api.routes.user.fetchSelf
import chat.revolt.api.schemas.* import chat.revolt.api.schemas.*
@ -98,13 +99,7 @@ object RevoltAPI {
} else { } else {
Log.e("RevoltAPI", "WebSocket error", e) Log.e("RevoltAPI", "WebSocket error", e)
} }
RealtimeSocket.open = false RealtimeSocket.updateDisconnectionState(DisconnectionState.Disconnected)
Log.i("RevoltAPI", "Reconnecting in 2.5 seconds...")
Thread.sleep(2500)
runBlocking {
connectWS()
} // FIXME ASAP move this to ChatRouting (whenever that's a thing) and do it properly
} }
} }
socketThread!!.start() socketThread!!.start()

View File

@ -2,6 +2,7 @@ package chat.revolt.api.realtime
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import chat.revolt.api.REVOLT_WEBSOCKET import chat.revolt.api.REVOLT_WEBSOCKET
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltHttp
@ -14,16 +15,29 @@ import io.ktor.websocket.*
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import java.util.Calendar import java.util.Calendar
enum class DisconnectionState {
Disconnected,
Reconnecting,
Connected
}
object RealtimeSocket { object RealtimeSocket {
var socket: WebSocketSession? = null var socket: WebSocketSession? = null
var open: Boolean = false
private var _disconnectionState = mutableStateOf(DisconnectionState.Disconnected)
val disconnectionState: DisconnectionState
get() = _disconnectionState.value
fun updateDisconnectionState(state: DisconnectionState) {
_disconnectionState.value = state
}
suspend fun connect(token: String) { suspend fun connect(token: String) {
RevoltHttp.ws(REVOLT_WEBSOCKET) { RevoltHttp.ws(REVOLT_WEBSOCKET) {
socket = this socket = this
Log.d("RealtimeSocket", "Connected to websocket.") Log.d("RealtimeSocket", "Connected to websocket.")
open = true updateDisconnectionState(DisconnectionState.Connected)
// Send authorization frame // Send authorization frame
val authFrame = AuthorizationFrame("Authenticate", token) val authFrame = AuthorizationFrame("Authenticate", token)
@ -46,7 +60,7 @@ object RealtimeSocket {
} }
suspend fun sendPing() { suspend fun sendPing() {
if (!open) return if (disconnectionState != DisconnectionState.Connected) return
val pingPacket = PingFrame("Ping", Calendar.getInstance().timeInMillis.toInt()) val pingPacket = PingFrame("Ping", Calendar.getInstance().timeInMillis.toInt())
socket?.send(RevoltJson.encodeToString(PingFrame.serializer(), pingPacket)) socket?.send(RevoltJson.encodeToString(PingFrame.serializer(), pingPacket))

View File

@ -0,0 +1,82 @@
package chat.revolt.components.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.api.realtime.DisconnectionState
@Composable
private fun DisconnectedNoticeBase(
background: Color,
icon: ImageVector,
text: String,
canTapToRetry: Boolean = false,
onRetry: () -> Unit = {}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(background)
.padding(vertical = 8.dp, horizontal = 16.dp)
.clickable(enabled = canTapToRetry, onClick = onRetry),
) {
Icon(
modifier = Modifier.padding(end = 8.dp),
imageVector = icon,
contentDescription = null
)
Text(
text = text,
fontWeight = FontWeight.Bold
)
if (canTapToRetry) {
Text(
text = stringResource(R.string.tap_to_reconnect),
modifier = Modifier.padding(start = 8.dp),
fontWeight = FontWeight.Normal
)
}
}
}
@Composable
fun DisconnectedNotice(
state: DisconnectionState,
onReconnect: () -> Unit
) {
when (state) {
DisconnectionState.Disconnected -> DisconnectedNoticeBase(
background = Color(0xfffe4654),
icon = Icons.Default.Warning,
text = stringResource(id = R.string.disconnected),
canTapToRetry = true,
onRetry = onReconnect
)
DisconnectionState.Reconnecting -> DisconnectedNoticeBase(
background = Color(0xfffcb205),
icon = Icons.Default.Refresh,
text = stringResource(id = R.string.reconnecting),
)
DisconnectionState.Connected -> DisconnectedNoticeBase(
background = Color(0xff4b9f6a),
icon = Icons.Default.Done,
text = stringResource(id = R.string.reconnected),
)
}
}

View File

@ -0,0 +1,57 @@
package chat.revolt.components.screens.splash
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
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.font.FontWeight
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 DisconnectedScreen(
onRetry: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.no_connection),
style = MaterialTheme.typography.displaySmall.copy(
fontSize = 30.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center
),
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 10.dp)
.fillMaxWidth(),
)
Text(
text = stringResource(R.string.no_connection_message),
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Normal,
),
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 10.dp)
.fillMaxWidth()
)
Button(onClick = onRetry) {
Text(stringResource(R.string.tap_to_retry))
}
}
}

View File

@ -1,5 +1,9 @@
package chat.revolt.screens package chat.revolt.screens
import android.annotation.SuppressLint
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -17,15 +21,20 @@ import chat.revolt.R
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.generic.drawableResource import chat.revolt.components.generic.drawableResource
import chat.revolt.components.screens.splash.DisconnectedScreen
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@SuppressLint("StaticFieldLeak")
class SplashScreenViewModel @Inject constructor( class SplashScreenViewModel @Inject constructor(
private val kvStorage: KVStorage private val kvStorage: KVStorage,
@ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
private var _navigateTo by mutableStateOf("") private var _navigateTo by mutableStateOf("")
val navigateTo: String val navigateTo: String
get() = _navigateTo get() = _navigateTo
@ -34,8 +43,36 @@ class SplashScreenViewModel @Inject constructor(
_navigateTo = value _navigateTo = value
} }
init { private var _isConnected by mutableStateOf(false)
val isConnected: Boolean
get() = _isConnected
fun setIsConnected(value: Boolean) {
_isConnected = value
}
private fun hasInternetConnection(): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkCapabilities = connectivityManager.activeNetwork ?: return false
val actNw =
connectivityManager.getNetworkCapabilities(networkCapabilities) ?: return false
return when {
actNw.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
actNw.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
actNw.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
else -> false
}
}
fun checkLoggedInState() {
viewModelScope.launch { viewModelScope.launch {
setIsConnected(hasInternetConnection())
if (!isConnected) return@launch
val token = kvStorage.get("sessionToken") ?: return@launch setNavigateTo("login") val token = kvStorage.get("sessionToken") ?: return@launch setNavigateTo("login")
val valid = RevoltAPI.checkSessionToken(token) val valid = RevoltAPI.checkSessionToken(token)
@ -49,10 +86,23 @@ class SplashScreenViewModel @Inject constructor(
} }
} }
} }
init {
checkLoggedInState()
}
} }
@Composable @Composable
fun SplashScreen(navController: NavController, viewModel: SplashScreenViewModel = hiltViewModel()) { fun SplashScreen(navController: NavController, viewModel: SplashScreenViewModel = hiltViewModel()) {
if (!viewModel.isConnected) {
DisconnectedScreen(
onRetry = {
viewModel.checkLoggedInState()
}
)
return
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -1,5 +1,6 @@
package chat.revolt.screens.chat package chat.revolt.screens.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -33,7 +34,10 @@ import chat.revolt.api.RevoltAPI
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.screens.chat.views.HomeScreen
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.chat.DisconnectedNotice
import chat.revolt.components.screens.chat.DrawerChannel import chat.revolt.components.screens.chat.DrawerChannel
import chat.revolt.screens.chat.views.ChannelScreen import chat.revolt.screens.chat.views.ChannelScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -60,120 +64,130 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
DismissibleNavigationDrawer( Column() {
drawerState = channelDrawerState, AnimatedVisibility(visible = RealtimeSocket.disconnectionState != DisconnectionState.Connected) {
drawerContent = { DisconnectedNotice(
ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) { state = RealtimeSocket.disconnectionState,
Column(Modifier.fillMaxWidth()) { onReconnect = {
Row { RealtimeSocket.updateDisconnectionState(DisconnectionState.Reconnecting)
Column( scope.launch { RevoltAPI.connectWS() }
modifier = Modifier })
.verticalScroll(rememberScrollState()) }
.background(MaterialTheme.colorScheme.surface) DismissibleNavigationDrawer(
) { drawerState = channelDrawerState,
IconButton( drawerContent = {
onClick = { viewModel.goToHome() }, ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) {
Column(Modifier.fillMaxWidth()) {
Row {
Column(
modifier = Modifier modifier = Modifier
.padding(8.dp) .verticalScroll(rememberScrollState())
.size(48.dp) .background(MaterialTheme.colorScheme.surface)
) { ) {
Icon( IconButton(
Icons.Default.Home, onClick = { viewModel.goToHome() },
contentDescription = stringResource(id = R.string.home), modifier = Modifier
modifier = Modifier.padding(4.dp) .padding(8.dp)
) .size(48.dp)
} ) {
Icon(
RevoltAPI.serverCache.values.forEach { server -> Icons.Default.Home,
if (server.name == null) return@forEach contentDescription = stringResource(id = R.string.home),
modifier = Modifier.padding(4.dp)
if (server.icon != null) {
RemoteImage(
url = "$REVOLT_FILES/icons/${server.icon.id!!}/server.png?max_side=256",
modifier = Modifier
.padding(8.dp)
.size(48.dp)
.clip(CircleShape)
.clickable { viewModel.setCurrentServer(server.id!!) },
description = "${server.name}"
) )
} else { }
// return a placeholder icon, currently the first letter of the server name in a circle
Box( RevoltAPI.serverCache.values.forEach { server ->
contentAlignment = Alignment.Center, if (server.name == null) return@forEach
modifier = Modifier
.padding(8.dp) if (server.icon != null) {
.size(48.dp) RemoteImage(
.clip(CircleShape) url = "$REVOLT_FILES/icons/${server.icon.id!!}/server.png?max_side=256",
.background(MaterialTheme.colorScheme.surfaceVariant) modifier = Modifier
.clickable { viewModel.setCurrentServer(server.id!!) } .padding(8.dp)
) { .size(48.dp)
Text( .clip(CircleShape)
text = server.name.first().toString(), .clickable { viewModel.setCurrentServer(server.id!!) },
fontSize = 20.sp, description = "${server.name}"
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
) )
} else {
// return a placeholder icon, currently the first letter of the server name in a circle
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { viewModel.setCurrentServer(server.id!!) }
) {
Text(
text = server.name.first().toString(),
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
}
} }
} }
} }
}
Crossfade(targetState = viewModel.currentServer) { Crossfade(targetState = viewModel.currentServer) {
Column( Column(
Modifier Modifier
.weight(1f) .weight(1f)
) { ) {
if (it == "home") { if (it == "home") {
Column( Column(
Modifier Modifier
.weight(1f) .weight(1f)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.DirectMessage } RevoltAPI.channelCache.values.filter { it.channelType == ChannelType.DirectMessage }
.forEach { channel -> .forEach { channel ->
DrawerChannel( DrawerChannel(
name = "DM #${channel.id}", // TODO get user or group name name = "DM #${channel.id}", // TODO get user or group name
channelType = ChannelType.DirectMessage, channelType = ChannelType.DirectMessage,
selected = channel.id == (navBackStackEntry?.arguments?.getString( selected = channel.id == (navBackStackEntry?.arguments?.getString(
"channelId" "channelId"
) ?: false), ) ?: false),
onClick = { onClick = {
navController.navigate("channel/${channel.id}") navController.navigate("channel/${channel.id}")
scope.launch { scope.launch {
channelDrawerState.close() channelDrawerState.close()
}
} }
} )
) }
} }
} } else {
} else { val server = RevoltAPI.serverCache[it]
val server = RevoltAPI.serverCache[it]
Text( Text(
text = server?.name ?: stringResource(R.string.unknown), text = server?.name ?: stringResource(R.string.unknown),
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
fontSize = 24.sp, fontSize = 24.sp,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
Column( Column(
Modifier Modifier
.weight(1f) .weight(1f)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
server?.channels?.forEach { channelId -> server?.channels?.forEach { 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 = navBackStackEntry?.arguments?.getString( selected = navBackStackEntry?.arguments?.getString(
"channelId" "channelId"
) == ch.id, ) == ch.id,
onClick = { onClick = {
scope.launch { channelDrawerState.close() } scope.launch { channelDrawerState.close() }
navController.navigate("channel/${ch.id}") navController.navigate("channel/${ch.id}")
}) })
}
} }
} }
} }
@ -182,18 +196,19 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
} }
} }
} }
} },
} modifier = Modifier.weight(1f),
) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = "home") { NavHost(navController = navController, startDestination = "home") {
composable("home") { composable("home") {
HomeScreen(navController = navController) HomeScreen(navController = navController)
} }
composable("channel/{channelId}") { backStackEntry -> composable("channel/{channelId}") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") val channelId = backStackEntry.arguments?.getString("channelId")
if (channelId != null) { if (channelId != null) {
ChannelScreen(navController, channelId = channelId) ChannelScreen(navController, channelId = channelId)
}
} }
} }
} }

View File

@ -1,21 +1,24 @@
package chat.revolt.screens.chat.views package chat.revolt.screens.chat.views
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.TextAlign 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.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController import androidx.navigation.NavController
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.components.screens.home.LinkOnHome
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch import chat.revolt.R
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
@ -23,37 +26,19 @@ import javax.inject.Inject
class HomeScreenViewModel @Inject constructor( class HomeScreenViewModel @Inject constructor(
private val kvStorage: KVStorage private val kvStorage: KVStorage
) : ViewModel() { ) : ViewModel() {
private var _messageContent by mutableStateOf("")
val messageContent: String
get() = _messageContent
fun setMessageContent(value: String) {
_messageContent = value
}
fun logout() { fun logout() {
runBlocking { runBlocking {
kvStorage.remove("sessionToken") kvStorage.remove("sessionToken")
RevoltAPI.logout() RevoltAPI.logout()
} }
} }
fun sendMessage() {
viewModelScope.launch {
chat.revolt.api.routes.channel.sendMessage(
"01F7ZSBSFHCAAJQ92ZGTY67HMN", // revolt lounge #general (temporarily hardcoded) FIXME
messageContent
)
}
setMessageContent("")
}
} }
@Composable @Composable
fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) { fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) {
Column() { Column() {
Text( Text(
text = "Home (placeholder)", text = stringResource(id = R.string.home),
style = MaterialTheme.typography.displaySmall.copy( style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
@ -64,7 +49,9 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi
.fillMaxWidth(), .fillMaxWidth(),
) )
Button( LinkOnHome(
heading = stringResource(id = R.string.logout),
icon = Icons.Default.Close,
onClick = { onClick = {
viewModel.logout() viewModel.logout()
navController.navigate("login/greeting") { navController.navigate("login/greeting") {
@ -72,12 +59,6 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi
inclusive = true inclusive = true
} }
} }
}, })
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 30.dp, top = 5.dp, start = 20.dp, end = 20.dp)
) {
Text("Logout")
}
} }
} }

View File

@ -75,7 +75,9 @@
<string name="select_channel">Select a server and channel by swiping from the left.</string> <string name="select_channel">Select a server and channel by swiping from the left.</string>
<string name="unknown">Unknown</string> <string name="unknown">Unknown</string>
<string name="home">Home</string> <string name="home">Home</string>
<string name="logout">Log out</string>
<string name="avatar_alt">%1$s\'s avatar</string> <string name="avatar_alt">%1$s\'s avatar</string>
@ -92,4 +94,13 @@
<string name="message_field_placeholder_notes">Add a note</string> <string name="message_field_placeholder_notes">Add a note</string>
<string name="reply_message_not_cached">Unknown message, tap to jump</string> <string name="reply_message_not_cached">Unknown message, tap to jump</string>
<string name="disconnected">Disconnected</string>
<string name="tap_to_reconnect">Tap to reconnect</string>
<string name="reconnecting">Reconnecting…</string>
<string name="reconnected">Reconnected</string>
<string name="no_connection">No connection</string>
<string name="no_connection_message">You are not connected to the internet. Please check your connection and try again.</string>
<string name="tap_to_retry">Tap to retry</string>
</resources> </resources>