feat: session settings

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-10-23 00:15:06 +02:00
parent 97c31d0e3e
commit 0245edc8f1
13 changed files with 455 additions and 8 deletions

1
.idea/.gitignore vendored
View File

@ -3,3 +3,4 @@
/workspace.xml /workspace.xml
/kotlinc.xml /kotlinc.xml
/appInsightsSettings.xml /appInsightsSettings.xml
/other.xml

View File

@ -43,6 +43,7 @@ import chat.revolt.screens.settings.AppearanceSettingsScreen
import chat.revolt.screens.settings.ChangelogsSettingsScreen import chat.revolt.screens.settings.ChangelogsSettingsScreen
import chat.revolt.screens.settings.ClosedBetaUpdaterScreen import chat.revolt.screens.settings.ClosedBetaUpdaterScreen
import chat.revolt.screens.settings.DebugSettingsScreen import chat.revolt.screens.settings.DebugSettingsScreen
import chat.revolt.screens.settings.SessionSettingsScreen
import chat.revolt.screens.settings.SettingsScreen import chat.revolt.screens.settings.SettingsScreen
import chat.revolt.ui.theme.RevoltTheme import chat.revolt.ui.theme.RevoltTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -142,6 +143,7 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) {
composable("chat") { ChatRouterScreen(navController, windowSizeClass) } composable("chat") { ChatRouterScreen(navController, windowSizeClass) }
composable("settings") { SettingsScreen(navController) } composable("settings") { SettingsScreen(navController) }
composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) } composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(navController) } composable("settings/debug") { DebugSettingsScreen(navController) }
composable("settings/updater") { ClosedBetaUpdaterScreen(navController) } composable("settings/updater") { ClosedBetaUpdaterScreen(navController) }

View File

@ -9,7 +9,6 @@ import chat.revolt.api.internals.Members
import chat.revolt.api.realtime.DisconnectionState 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.Channel as ChannelSchema
import chat.revolt.api.schemas.Emoji import chat.revolt.api.schemas.Emoji
import chat.revolt.api.schemas.Message import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.Server import chat.revolt.api.schemas.Server
@ -39,6 +38,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import chat.revolt.api.schemas.Channel as ChannelSchema
const val REVOLT_BASE = "https://api.revolt.chat" const val REVOLT_BASE = "https://api.revolt.chat"
const val REVOLT_SUPPORT = "https://support.revolt.chat" const val REVOLT_SUPPORT = "https://support.revolt.chat"
@ -118,6 +118,8 @@ object RevoltAPI {
var sessionToken: String = "" var sessionToken: String = ""
private set private set
var sessionId: String = ""
private set
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
val realtimeContext = newSingleThreadContext("RealtimeContext") val realtimeContext = newSingleThreadContext("RealtimeContext")
@ -129,6 +131,10 @@ object RevoltAPI {
sessionToken = token sessionToken = token
} }
fun setSessionId(id: String) {
sessionId = id
}
suspend fun loginAs(token: String) { suspend fun loginAs(token: String) {
setSessionHeader(token) setSessionHeader(token)
fetchSelf() fetchSelf()
@ -189,6 +195,7 @@ object RevoltAPI {
fun logout() { fun logout() {
selfId = null selfId = null
sessionToken = "" sessionToken = ""
sessionId = ""
userCache.clear() userCache.clear()
serverCache.clear() serverCache.clear()

View File

@ -0,0 +1,30 @@
package chat.revolt.api.routes.auth
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.Session
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.builtins.ListSerializer
suspend fun fetchAllSessions(): List<Session> {
val response = RevoltHttp.get("/auth/session/all")
.bodyAsText()
return RevoltJson.decodeFromString(
ListSerializer(Session.serializer()),
response
)
}
suspend fun logoutSessionById(id: String) {
RevoltHttp.delete("/auth/session/$id")
}
suspend fun logoutAllSessions(includingSelf: Boolean = false) {
RevoltHttp.delete("/auth/session/all") {
parameter("revoke_self", includingSelf)
}
}

View File

@ -0,0 +1,15 @@
package chat.revolt.api.schemas
import chat.revolt.api.RevoltAPI
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Session(
@SerialName("_id") val id: String,
val name: String,
) {
fun isCurrent(): Boolean {
return id == RevoltAPI.sessionId
}
}

View File

@ -0,0 +1,87 @@
package chat.revolt.components.settings.sessions
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.api.internals.ULID
import chat.revolt.api.schemas.Session
@Composable
fun SessionItem(
session: Session,
modifier: Modifier = Modifier,
currentSession: Boolean = false,
onLogout: (Session) -> Unit
) {
val context = LocalContext.current
val decodedUlid by remember(session) { mutableLongStateOf(ULID.asTimestamp(session.id)) }
val formattedTimestamp = remember(decodedUlid) {
DateUtils.getRelativeTimeSpanString(
decodedUlid,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS
)
}
Row(
modifier = modifier
.fillMaxWidth()
.clip(shape = MaterialTheme.shapes.medium)
.background(
color = if (currentSession) {
MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
}
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
) {
Text(
text = session.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelLarge
)
Text(
text = stringResource(R.string.settings_sessions_first_seen, formattedTimestamp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall
)
}
if (!currentSession) {
ElevatedButton(onClick = { onLogout(session) }) {
Text(stringResource(R.string.logout))
}
}
}
}

View File

@ -11,9 +11,17 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -37,8 +45,8 @@ import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.request.get import io.ktor.client.request.get
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
@ -99,6 +107,7 @@ class SplashScreenViewModel @Inject constructor(
} }
val token = kvStorage.get("sessionToken") ?: return@launch setNavigateTo("login") val token = kvStorage.get("sessionToken") ?: return@launch setNavigateTo("login")
val id = kvStorage.get("sessionId") ?: ""
val canReachRevolt = canReachRevolt() val canReachRevolt = canReachRevolt()
val valid = RevoltAPI.checkSessionToken(token) val valid = RevoltAPI.checkSessionToken(token)
@ -110,6 +119,7 @@ class SplashScreenViewModel @Inject constructor(
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
kvStorage.remove("sessionToken") kvStorage.remove("sessionToken")
kvStorage.remove("sessionId")
setNavigateTo("login") setNavigateTo("login")
} else { } else {
val onboard = needsOnboarding() val onboard = needsOnboarding()
@ -119,6 +129,7 @@ class SplashScreenViewModel @Inject constructor(
} }
RevoltAPI.loginAs(token) RevoltAPI.loginAs(token)
RevoltAPI.setSessionId(id)
loadSettings() loadSettings()
setNavigateTo("home") setNavigateTo("home")
} }

View File

@ -49,8 +49,8 @@ import chat.revolt.components.generic.FormTextField
import chat.revolt.components.generic.Weblink import chat.revolt.components.generic.Weblink
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
@ -102,8 +102,10 @@ class LoginViewModel @Inject constructor(
try { try {
val token = response.firstUserHints!!.token val token = response.firstUserHints!!.token
val id = response.firstUserHints.id
kvStorage.set("sessionToken", token) kvStorage.set("sessionToken", token)
kvStorage.set("sessionId", id)
val onboard = needsOnboarding(token) val onboard = needsOnboarding(token)
if (onboard) { if (onboard) {
@ -112,6 +114,7 @@ class LoginViewModel @Inject constructor(
} }
RevoltAPI.loginAs(token) RevoltAPI.loginAs(token)
RevoltAPI.setSessionId(response.firstUserHints.token)
loadSettings(token) loadSettings(token)
_navigateTo = "home" _navigateTo = "home"

View File

@ -48,8 +48,8 @@ import chat.revolt.components.generic.CollapsibleCard
import chat.revolt.components.generic.FormTextField import chat.revolt.components.generic.FormTextField
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MfaScreenViewModel @Inject constructor( class MfaScreenViewModel @Inject constructor(
@ -98,10 +98,13 @@ class MfaScreenViewModel @Inject constructor(
try { try {
val token = response.firstUserHints!!.token val token = response.firstUserHints!!.token
val id = response.firstUserHints.id
RevoltAPI.loginAs(token) RevoltAPI.loginAs(token)
RevoltAPI.setSessionId(id)
loadSettings(token) loadSettings(token)
kvStorage.set("sessionToken", token) kvStorage.set("sessionToken", token)
kvStorage.set("sessionId", id)
_navigateToHome = true _navigateToHome = true
} catch (e: Error) { } catch (e: Error) {
@ -126,10 +129,13 @@ class MfaScreenViewModel @Inject constructor(
try { try {
val token = response.firstUserHints!!.token val token = response.firstUserHints!!.token
val id = response.firstUserHints.id
RevoltAPI.loginAs(token) RevoltAPI.loginAs(token)
RevoltAPI.setSessionId(id)
loadSettings(token) loadSettings(token)
kvStorage.set("sessionToken", token) kvStorage.set("sessionToken", token)
kvStorage.set("sessionId", id)
_navigateToHome = true _navigateToHome = true
} catch (e: Error) { } catch (e: Error) {

View File

@ -0,0 +1,239 @@
package chat.revolt.screens.settings
import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.safeDrawingPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.auth.fetchAllSessions
import chat.revolt.api.routes.auth.logoutAllSessions
import chat.revolt.api.routes.auth.logoutSessionById
import chat.revolt.api.schemas.Session
import chat.revolt.components.generic.PageHeader
import chat.revolt.components.generic.UIMarkdown
import chat.revolt.components.settings.sessions.SessionItem
import kotlinx.coroutines.launch
class SessionSettingsScreenViewModel : ViewModel() {
val sessions = mutableStateListOf<Session>()
var currentSession by mutableStateOf<Session?>(null)
var showLogoutOtherConfirmation by mutableStateOf(false)
fun fetchSessions() {
viewModelScope.launch {
sessions.addAll(fetchAllSessions())
currentSession = sessions.firstOrNull { it.isCurrent() }
Log.d(
"SessionSettingsScreen",
"Current session: $currentSession. Current session ID: ${RevoltAPI.sessionId}"
)
}
}
fun logoutSession(id: String) {
viewModelScope.launch {
logoutSessionById(id)
sessions.removeIf { it.id == id }
}
}
fun logoutOtherSessions() {
viewModelScope.launch {
logoutAllSessions(includingSelf = false)
sessions.clear()
sessions.addAll(fetchAllSessions())
}
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SessionSettingsScreen(
navController: NavController,
viewModel: SessionSettingsScreenViewModel = viewModel()
) {
LaunchedEffect(Unit) {
viewModel.fetchSessions()
}
if (viewModel.showLogoutOtherConfirmation) {
AlertDialog(
onDismissRequest = {
viewModel.showLogoutOtherConfirmation = false
},
title = {
Text(
text = stringResource(R.string.settings_sessions_log_out_other_confirm)
)
},
confirmButton = {
Button(
onClick = {
viewModel.showLogoutOtherConfirmation = false
viewModel.logoutOtherSessions()
}
) {
Text(stringResource(R.string.settings_sessions_log_out_other_confirm_yes))
}
},
dismissButton = {
TextButton(
onClick = {
viewModel.showLogoutOtherConfirmation = false
}
) {
Text(stringResource(R.string.settings_sessions_log_out_other_confirm_no))
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
PageHeader(
text = stringResource(id = R.string.settings_sessions),
showBackButton = true,
onBackButtonClicked = {
navController.popBackStack()
}
)
LazyColumn {
stickyHeader(key = "thisDevice") {
Text(
text = stringResource(id = R.string.settings_sessions_this_device),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(10.dp)
)
}
viewModel.currentSession?.let {
item(key = it.id) {
Spacer(Modifier.height(8.dp))
SessionItem(
session = it,
currentSession = true,
onLogout = {},
modifier = Modifier.padding(horizontal = 8.dp)
)
}
} ?: run {
item(key = "noCurrentSession") {
UIMarkdown(
text = stringResource(id = R.string.settings_sessions_this_device_unavailable),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(10.dp)
)
}
}
stickyHeader(key = "otherSessions") {
Text(
text = stringResource(id = R.string.settings_sessions_other_sessions),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(10.dp)
)
}
item(key = "logoutOtherSessions") {
Row(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
.clip(shape = MaterialTheme.shapes.medium)
.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
) {
Text(
text = stringResource(R.string.settings_sessions_log_out_other),
style = MaterialTheme.typography.labelLarge
)
Text(
text = stringResource(R.string.settings_sessions_log_out_other_description),
style = MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Normal
)
)
}
FilledTonalButton(onClick = { viewModel.showLogoutOtherConfirmation = true }) {
Text(stringResource(R.string.logout))
}
}
Spacer(Modifier.height(8.dp))
}
items(viewModel.sessions.size) {
val item = viewModel.sessions[it]
if (item.isCurrent()) {
return@items
}
SessionItem(
session = item,
onLogout = { session ->
viewModel.logoutSession(session.id)
},
modifier = Modifier.padding(horizontal = 8.dp)
)
Spacer(Modifier.height(16.dp))
}
}
}
}

View File

@ -33,8 +33,8 @@ import chat.revolt.components.generic.SheetClickable
import chat.revolt.components.screens.settings.SelfUserOverview import chat.revolt.components.screens.settings.SelfUserOverview
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsScreenViewModel @Inject constructor( class SettingsScreenViewModel @Inject constructor(
@ -79,10 +79,35 @@ fun SettingsScreen(
.fillMaxSize() .fillMaxSize()
.padding(10.dp) .padding(10.dp)
) { ) {
Text(
text = stringResource(id = R.string.settings_category_account),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 10.dp, start = 10.dp, top = 20.dp)
)
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(R.drawable.ic_tablet_cellphone_24dp),
contentDescription = stringResource(id = R.string.settings_sessions),
modifier = modifier
)
},
label = { textStyle ->
Text(
text = stringResource(id = R.string.settings_sessions),
style = textStyle
)
},
modifier = Modifier.testTag("settings_view_sessions")
) {
navController.navigate("settings/sessions")
}
Text( Text(
text = stringResource(id = R.string.settings_category_general), text = stringResource(id = R.string.settings_category_general),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 10.dp, start = 10.dp) modifier = Modifier.padding(bottom = 10.dp, start = 10.dp, top = 20.dp)
) )
SheetClickable( SheetClickable(

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M3,4H20A2,2 0 0,1 22,6V8H18V6H5V18H14V20H3A2,2 0 0,1 1,18V6A2,2 0 0,1 3,4M17,10H23A1,1 0 0,1 24,11V21A1,1 0 0,1 23,22H17A1,1 0 0,1 16,21V11A1,1 0 0,1 17,10M18,12V19H22V12H18Z" />
</vector>

View File

@ -369,10 +369,22 @@
<string name="notice_platform_mod_dm_description">You have received an important notice regarding your account from our moderation team. Please read it carefully.</string> <string name="notice_platform_mod_dm_description">You have received an important notice regarding your account from our moderation team. Please read it carefully.</string>
<string name="notice_platform_mod_dm_acknowledge">View</string> <string name="notice_platform_mod_dm_acknowledge">View</string>
<string name="settings_category_account">Account</string>
<string name="settings_category_general">General</string> <string name="settings_category_general">General</string>
<string name="settings_category_miscellaneous">Miscellaneous</string> <string name="settings_category_miscellaneous">Miscellaneous</string>
<string name="settings_category_last" translatable="false">Revolt v%1$s</string> <string name="settings_category_last" translatable="false">Revolt v%1$s</string>
<string name="settings_sessions">Sessions</string>
<string name="settings_sessions_this_device">This Device</string>
<string name="settings_sessions_this_device_unavailable">The session of this device is unavailable. Please **log in again** to view your current session.</string>
<string name="settings_sessions_other_sessions">Other Active Sessions</string>
<string name="settings_sessions_first_seen">First seen %1$s</string>
<string name="settings_sessions_log_out_other">Log out other sessions</string>
<string name="settings_sessions_log_out_other_description">This device will stay logged in.</string>
<string name="settings_sessions_log_out_other_confirm">Log out all other sessions?</string>
<string name="settings_sessions_log_out_other_confirm_yes">Log out</string>
<string name="settings_sessions_log_out_other_confirm_no">Keep logged in</string>
<string name="settings_appearance">Appearance</string> <string name="settings_appearance">Appearance</string>
<string name="settings_appearance_theme">Theme</string> <string name="settings_appearance_theme">Theme</string>
<string name="settings_appearance_theme_none">System</string> <string name="settings_appearance_theme_none">System</string>