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
/kotlinc.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.ClosedBetaUpdaterScreen
import chat.revolt.screens.settings.DebugSettingsScreen
import chat.revolt.screens.settings.SessionSettingsScreen
import chat.revolt.screens.settings.SettingsScreen
import chat.revolt.ui.theme.RevoltTheme
import dagger.hilt.android.AndroidEntryPoint
@ -142,6 +143,7 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) {
composable("chat") { ChatRouterScreen(navController, windowSizeClass) }
composable("settings") { SettingsScreen(navController) }
composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(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.RealtimeSocket
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.Message
import chat.revolt.api.schemas.Server
@ -39,6 +38,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import chat.revolt.api.schemas.Channel as ChannelSchema
const val REVOLT_BASE = "https://api.revolt.chat"
const val REVOLT_SUPPORT = "https://support.revolt.chat"
@ -118,6 +118,8 @@ object RevoltAPI {
var sessionToken: String = ""
private set
var sessionId: String = ""
private set
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
val realtimeContext = newSingleThreadContext("RealtimeContext")
@ -129,6 +131,10 @@ object RevoltAPI {
sessionToken = token
}
fun setSessionId(id: String) {
sessionId = id
}
suspend fun loginAs(token: String) {
setSessionHeader(token)
fetchSelf()
@ -189,6 +195,7 @@ object RevoltAPI {
fun logout() {
selfId = null
sessionToken = ""
sessionId = ""
userCache.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.compose.foundation.Image
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.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.Modifier
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.qualifiers.ApplicationContext
import io.ktor.client.request.get
import javax.inject.Inject
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
@SuppressLint("StaticFieldLeak")
@ -99,6 +107,7 @@ class SplashScreenViewModel @Inject constructor(
}
val token = kvStorage.get("sessionToken") ?: return@launch setNavigateTo("login")
val id = kvStorage.get("sessionId") ?: ""
val canReachRevolt = canReachRevolt()
val valid = RevoltAPI.checkSessionToken(token)
@ -110,6 +119,7 @@ class SplashScreenViewModel @Inject constructor(
Toast.LENGTH_SHORT
).show()
kvStorage.remove("sessionToken")
kvStorage.remove("sessionId")
setNavigateTo("login")
} else {
val onboard = needsOnboarding()
@ -119,6 +129,7 @@ class SplashScreenViewModel @Inject constructor(
}
RevoltAPI.loginAs(token)
RevoltAPI.setSessionId(id)
loadSettings()
setNavigateTo("home")
}

View File

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

View File

@ -48,8 +48,8 @@ import chat.revolt.components.generic.CollapsibleCard
import chat.revolt.components.generic.FormTextField
import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MfaScreenViewModel @Inject constructor(
@ -98,10 +98,13 @@ class MfaScreenViewModel @Inject constructor(
try {
val token = response.firstUserHints!!.token
val id = response.firstUserHints.id
RevoltAPI.loginAs(token)
RevoltAPI.setSessionId(id)
loadSettings(token)
kvStorage.set("sessionToken", token)
kvStorage.set("sessionId", id)
_navigateToHome = true
} catch (e: Error) {
@ -126,10 +129,13 @@ class MfaScreenViewModel @Inject constructor(
try {
val token = response.firstUserHints!!.token
val id = response.firstUserHints.id
RevoltAPI.loginAs(token)
RevoltAPI.setSessionId(id)
loadSettings(token)
kvStorage.set("sessionToken", token)
kvStorage.set("sessionId", id)
_navigateToHome = true
} 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.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltViewModel
class SettingsScreenViewModel @Inject constructor(
@ -79,10 +79,35 @@ fun SettingsScreen(
.fillMaxSize()
.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 = stringResource(id = R.string.settings_category_general),
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(

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_acknowledge">View</string>
<string name="settings_category_account">Account</string>
<string name="settings_category_general">General</string>
<string name="settings_category_miscellaneous">Miscellaneous</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_theme">Theme</string>
<string name="settings_appearance_theme_none">System</string>