feat: a real-time websocket connection

- more schemas
 - schemas for all sendable and receivable socket frames(packets)
 - multi-threading woohoo!
 - handling for ready event
 - sending out the ping event in 30-second intervals
 - connect-disconnect-reconnect logic (implemented, but to be used)
 - caches for all the things!
This commit is contained in:
Infi 2022-12-23 03:43:20 +01:00
parent 851bbebd43
commit 589c00d3ee
14 changed files with 651 additions and 69 deletions

View File

@ -1,20 +1,27 @@
package chat.revolt.api
import android.os.Handler
import android.os.Looper
import android.util.Log
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf
import chat.revolt.api.routes.user.fetchSelfWithNewToken
import chat.revolt.api.schemas.CompleteUser
import chat.revolt.api.schemas.*
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.plugins.websocket.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.Serializable
const val REVOLT_BASE = "https://api.revolt.chat"
const val REVOLT_SUPPORT = "https://support.revolt.chat"
const val REVOLT_MARKETING = "https://revolt.chat"
const val REVOLT_FILES = "https://autumn.revolt.chat"
const val REVOLT_WEBSOCKET = "wss://ws.revolt.chat"
private const val BACKEND_IS_STABLE = false
@ -26,6 +33,8 @@ val RevoltHttp = HttpClient(OkHttp) {
json(RevoltJson)
}
install(WebSockets)
if (BACKEND_IS_STABLE) {
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 5)
@ -46,23 +55,70 @@ val RevoltHttp = HttpClient(OkHttp) {
}
}
val mainHandler = Handler(Looper.getMainLooper())
object RevoltAPI {
const val TOKEN_HEADER_NAME = "x-session-token"
// discount caching solution(/-s)! LRU would be better but this is fine for now, until it's not...
val userCache =
mutableMapOf<String, CompleteUser>()
// FIXME discount caching solutions! LRU would be better but this is fine for now
val userCache = mutableMapOf<String, User>()
val serverCache = mutableMapOf<String, Server>()
val channelCache = mutableMapOf<String, Channel>()
val emojiCache = mutableMapOf<String, Emoji>()
val messageCache = mutableMapOf<String, Message>()
var selfId: String? = null
var sessionToken: String = ""
private set
private var socketThread: Thread? = null
fun setSessionHeader(token: String) {
sessionToken = token
}
suspend fun loginAs(token: String) {
setSessionHeader(token)
fetchSelf()
startSocketOps()
}
suspend fun connectWS() {
socketThread = Thread {
try {
runBlocking {
RealtimeSocket.connect(sessionToken)
}
} catch (e: Exception) {
if (e is InterruptedException) {
Log.d("RevoltAPI", "Socket interrupted")
} else {
Log.e("RevoltAPI", "WebSocket error", e)
}
RealtimeSocket.open = false
}
}
socketThread!!.start()
}
private suspend fun startSocketOps() {
connectWS()
// Send a ping every roughly 30 seconds else the socket dies
// Same interval as the web clients (/revolt.js)
// Note: This will run even if the socket is closed (sendPing will just exit early)
mainHandler.post(object : Runnable {
override fun run() {
runBlocking {
RealtimeSocket.sendPing()
}
mainHandler.postDelayed(this, 30 * 1000)
}
})
}
suspend fun initialize() {
if (sessionToken != "") {
fetchSelf()
@ -85,6 +141,11 @@ object RevoltAPI {
sessionToken = ""
userCache.clear()
serverCache.clear()
channelCache.clear()
emojiCache.clear()
socketThread?.interrupt()
}
/**
@ -92,7 +153,8 @@ object RevoltAPI {
*/
suspend fun checkSessionToken(token: String): Boolean {
return try {
fetchSelfWithNewToken(token)
setSessionHeader(token)
fetchSelf()
true
} catch (e: Exception) {
false
@ -100,5 +162,5 @@ object RevoltAPI {
}
}
@kotlinx.serialization.Serializable
@Serializable
data class RevoltError(val type: String)

View File

@ -0,0 +1,108 @@
package chat.revolt.api.realtime
import android.util.Log
import chat.revolt.api.REVOLT_WEBSOCKET
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.realtime.frames.receivable.*
import chat.revolt.api.realtime.frames.sendable.AuthorizationFrame
import chat.revolt.api.realtime.frames.sendable.PingFrame
import io.ktor.client.plugins.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.channels.consumeEach
import java.util.Calendar
object RealtimeSocket {
var socket: WebSocketSession? = null
var open: Boolean = false
suspend fun connect(token: String) {
RevoltHttp.ws(REVOLT_WEBSOCKET) {
socket = this
Log.d("RealtimeSocket", "Connected to websocket.")
open = true
// Send authorization frame
val authFrame = AuthorizationFrame("Authenticate", token)
val authFrameString =
RevoltJson.encodeToString(AuthorizationFrame.serializer(), authFrame)
Log.d("RealtimeSocket", "Sending authorization frame: $authFrameString")
send(RevoltJson.encodeToString(AuthorizationFrame.serializer(), authFrame))
incoming.consumeEach { frame ->
if (frame is Frame.Text) {
val frameString = frame.readText()
val frameType =
RevoltJson.decodeFromString(AnyFrame.serializer(), frameString).type
handleFrame(frameType, frameString)
}
}
}
}
suspend fun sendPing() {
if (!open) return
val pingPacket = PingFrame("Ping", Calendar.getInstance().timeInMillis.toInt())
socket?.send(RevoltJson.encodeToString(PingFrame.serializer(), pingPacket))
Log.d("RealtimeSocket", "Sent ping frame with ${pingPacket.data}")
}
private fun handleFrame(type: String, rawFrame: String) {
when (type) {
"Pong" -> {
val pongFrame = RevoltJson.decodeFromString(PongFrame.serializer(), rawFrame)
Log.d("RealtimeSocket", "Received pong frame for ${pongFrame.data}")
}
"Bulk" -> {
val bulkFrame = RevoltJson.decodeFromString(BulkFrame.serializer(), rawFrame)
Log.d("RealtimeSocket", "Received bulk frame with ${bulkFrame.v.size} sub-frames.")
bulkFrame.v.forEach { subFrame ->
val subFrameType =
RevoltJson.decodeFromString(AnyFrame.serializer(), subFrame.toString()).type
handleFrame(subFrameType, subFrame.toString())
}
}
"Ready" -> {
val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received ready frame with ${readyFrame.users.size} users, ${readyFrame.servers.size} servers, ${readyFrame.channels.size} channels, and ${readyFrame.emojis.size} emojis."
)
Log.d("RealtimeSocket", "Adding users to cache.")
readyFrame.users.forEach { user ->
RevoltAPI.userCache[user.id!!] = user
}
Log.d("RealtimeSocket", "Adding servers to cache.")
readyFrame.servers.forEach { server ->
RevoltAPI.serverCache[server.id!!] = server
}
Log.d("RealtimeSocket", "Adding channels to cache.")
readyFrame.channels.forEach { channel ->
RevoltAPI.channelCache[channel.id!!] = channel
}
Log.d("RealtimeSocket", "Adding emojis to cache.")
readyFrame.emojis.forEach { emoji ->
RevoltAPI.emojiCache[emoji.id!!] = emoji
}
}
"UserUpdate" -> {
val userUpdateFrame =
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)
// We will genuinely just ignore this frame for now, but it gets really spammy in the logs
// FIXME handle this frame
}
else -> {
Log.i("RealtimeSocket", "Unknown frame: $rawFrame")
}
}
}
}

View File

@ -0,0 +1,239 @@
package chat.revolt.api.realtime.frames.receivable
import chat.revolt.api.schemas.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class AnyFrame(
val type: String,
)
@Serializable
data class ErrorFrame(
val type: String = "Error",
val error: String,
)
@Serializable
data class BulkFrame(
val type: String = "Bulk",
val v: List<JsonObject>
)
@Serializable
data class PongFrame(
val type: String = "Pong",
val data: Int
)
@Serializable
data class ReadyFrame(
val type: String = "Ready",
val users: List<User>,
val servers: List<Server>,
val channels: List<Channel>,
val emojis: List<Emoji>
)
typealias MessageFrame = Message
@Serializable
data class MessageUpdateFrame(
val type: String = "MessageUpdate",
val id: String,
val channel: String,
val data: JsonObject
)
@Serializable
data class Appendable(
val embeds: List<Embed>? = null,
)
@Serializable
data class MessageAppendFrame(
val type: String = "MessageAppend",
val id: String,
val channel: String,
val append: Appendable
)
@Serializable
data class MessageDeleteFrame(
val type: String = "MessageDelete",
val id: String,
val channel: String
)
@Serializable
data class MessageReactFrame(
val type: String = "MessageReact",
val id: String,
val channel_id: String,
val user_id: String,
val emoji_id: String,
)
@Serializable
data class MessageUnreactFrame(
val type: String = "MessageUnreact",
val id: String,
val channel_id: String,
val user_id: String,
val emoji_id: String,
)
@Serializable
data class MessageRemoveReactionFrame(
val type: String = "MessageRemoveReaction",
val id: String,
val channel_id: String,
val emoji_id: String,
)
/* ChannelCreate: we already have a "type" property in channel so we just alias the type */
typealias ChannelCreateFrame = Channel
@Serializable
data class ChannelUpdateFrame(
val type: String = "ChannelUpdate",
val id: String,
val data: Channel,
val clear: List<String>? = null // "Icon" or "Description"
)
@Serializable
data class ChannelDeleteFrame(
val type: String = "ChannelDelete",
val id: String
)
@Serializable
data class ChannelGroupJoinFrame(
val type: String = "ChannelGroupJoin",
val id: String,
val user: String
)
@Serializable
data class ChannelGroupLeaveFrame(
val type: String = "ChannelGroupLeave",
val id: String,
val user: String
)
@Serializable
data class ChannelStartTypingFrame(
val type: String = "ChannelStartTyping",
val id: String,
val user: String
)
@Serializable
data class ChannelStopTypingFrame(
val type: String = "ChannelStopTyping",
val id: String,
val user: String
)
@Serializable
data class ChannelAckFrame(
val type: String = "ChannelAck",
val id: String,
val user: String,
@SerialName("message_id")
val messageId: String
)
@Serializable
data class ServerCreateFrame(
val type: String = "ServerCreate",
val id: String,
val server: Server
)
@Serializable
data class ServerUpdateFrame(
val type: String = "ServerUpdate",
val id: String,
val data: Server,
val clear: List<String>? = null // "Icon", "Banner" or "Description"
)
@Serializable
data class ServerDeleteFrame(
val type: String = "ServerDelete",
val id: String
)
@Serializable
data class ServerUserChoice(
val server: String,
val user: String,
)
@Serializable
data class ServerMemberUpdateFrame(
val type: String = "ServerMemberUpdate",
val id: ServerUserChoice,
val data: Member,
val clear: List<String>? = null // "Nickname" or "Avatar"
)
@Serializable
data class ServerMemberJoinFrame(
val type: String = "ServerMemberJoin",
val id: String,
val user: String
)
@Serializable
data class ServerMemberLeaveFrame(
val type: String = "ServerMemberLeave",
val id: String,
val user: String
)
@Serializable
data class ServerRoleUpdateFrame(
val type: String = "ServerRoleUpdate",
val id: String,
@SerialName("role_id")
val roleId: String,
val data: Role,
val clear: List<String>? = null // "Colour"
)
@Serializable
data class ServerRoleDeleteFrame(
val type: String = "ServerRoleDelete",
val id: String,
@SerialName("role_id")
val roleId: String
)
@Serializable
data class UserUpdateFrame(
val type: String = "UserUpdate",
val id: String,
val data: User,
val clear: List<String>? = null // "ProfileContent", "ProfileBackground", "StatusText" or "Avatar"
)
@Serializable
data class UserRelationshipFrame(
val type: String = "UserRelationship",
val id: String,
val user: User,
val status: String,
)
typealias EmojiCreateFrame = Emoji
@Serializable
data class EmojiDeleteFrame(
val type: String = "EmojiDelete",
val id: String,
)

View File

@ -0,0 +1,27 @@
package chat.revolt.api.realtime.frames.sendable
import kotlinx.serialization.Serializable
@Serializable
data class AuthorizationFrame(
val type: String,
val token: String
)
@Serializable
data class PingFrame(
val type: String,
val data: Int
)
@Serializable
data class BeginTypingFrame(
val type: String,
val channel: String
)
@Serializable
data class EndTypingFrame(
val type: String,
val channel: String
)

View File

@ -4,12 +4,12 @@ import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.CompleteUser
import chat.revolt.api.schemas.User
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.serialization.SerializationException
suspend fun fetchSelf(): CompleteUser {
suspend fun fetchSelf(): User {
val response = RevoltHttp.get("/users/@me") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
}
@ -22,15 +22,10 @@ suspend fun fetchSelf(): CompleteUser {
// Not an error
}
val user = RevoltJson.decodeFromString(CompleteUser.serializer(), response)
val user = RevoltJson.decodeFromString(User.serializer(), response)
RevoltAPI.userCache[user.id!!] = user
RevoltAPI.selfId = user.id
return user
}
suspend fun fetchSelfWithNewToken(token: String): CompleteUser {
RevoltAPI.setSessionHeader(token)
return fetchSelf()
}

View File

@ -8,7 +8,7 @@ import kotlinx.serialization.encoding.*
@Serializable
data class MessagesInChannel(
val messages: List<Message>? = null,
val users: List<CompleteUser>? = null,
val users: List<User>? = null,
val members: List<Member>? = null
)
@ -20,7 +20,61 @@ data class Member(
@SerialName("joined_at")
val joinedAt: String? = null,
val avatar: Avatar? = null,
val avatar: AutumnResource? = null,
val roles: List<String>? = null,
val nickname: String? = null
)
)
@Serializable
data class Channel(
val channelType: ChannelType? = null,
@SerialName("_id")
val id: String? = null,
val user: String? = null,
val name: String? = null,
val owner: String? = null,
val description: String? = null,
val recipients: List<String>? = null,
val icon: AutumnResource? = null,
val lastMessageID: String? = null,
val active: Boolean? = null,
val permissions: Long? = null,
val server: String? = null,
val rolePermissions: Map<String, DefaultPermissions>? = null,
val defaultPermissions: DefaultPermissions? = null,
val nsfw: Boolean? = null,
val type: String? = null, // this is _only_ used for websocket events!
)
@Serializable
enum class ChannelType(val value: String) {
DirectMessage("DirectMessage"),
Group("Group"),
SavedMessages("SavedMessages"),
TextChannel("TextChannel"),
VoiceChannel("VoiceChannel");
companion object : KSerializer<ChannelType> {
override val descriptor: SerialDescriptor
get() {
return PrimitiveSerialDescriptor(
"chat.revolt.api.schemas.ChannelType",
PrimitiveKind.STRING
)
}
override fun deserialize(decoder: Decoder): ChannelType =
when (val value = decoder.decodeString()) {
"DirectMessage" -> DirectMessage
"Group" -> Group
"SavedMessages" -> SavedMessages
"TextChannel" -> TextChannel
"VoiceChannel" -> VoiceChannel
else -> throw IllegalArgumentException("ChannelType could not parse: $value")
}
override fun serialize(encoder: Encoder, value: ChannelType) {
return encoder.encodeString(value.value)
}
}
}

View File

@ -0,0 +1,40 @@
package chat.revolt.api.schemas
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AutumnResource(
@SerialName("_id")
val id: String? = null,
val tag: String? = null,
val filename: String? = null,
val metadata: Metadata? = null,
@SerialName("content_type")
val contentType: String? = null,
val size: Long? = null,
val deleted: Boolean? = null,
val reported: Boolean? = null,
@SerialName("message_id")
val messageID: String? = null,
@SerialName("user_id")
val userID: String? = null,
@SerialName("server_id")
val serverID: String? = null,
@SerialName("object_id")
val objectID: String? = null
)
@Serializable
data class Metadata(
val type: String? = null,
val width: Long? = null,
val height: Long? = null
)

View File

@ -7,17 +7,17 @@ import kotlinx.serialization.Serializable
data class Message(
@SerialName("_id")
val id: String? = null,
val nonce: String? = null,
val channel: String? = null,
val author: String? = null,
val content: String? = null,
val reactions: Map<String, List<String>>? = null,
val replies: List<String>? = null,
val attachments: List<Avatar>? = null,
val attachments: List<AutumnResource>? = null,
val edited: String? = null,
val embeds: List<Embed>? = null,
val mentions: List<String>? = null
val mentions: List<String>? = null,
val type: String? = null, // this is _only_ used for websocket events!
)
@Serializable

View File

@ -0,0 +1,70 @@
package chat.revolt.api.schemas
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Server(
@SerialName("_id")
val id: String? = null,
val owner: String? = null,
val name: String? = null,
val description: String? = null,
val channels: List<String>? = null,
val categories: List<Category>? = null,
val systemMessages: SystemMessages? = null,
val roles: Map<String, Role>? = null,
val defaultPermissions: Long? = null,
val icon: AutumnResource? = null,
val banner: AutumnResource? = null,
val flags: Long? = null,
val analytics: Boolean? = null,
val discoverable: Boolean? = null,
)
@Serializable
data class Category(
val id: String? = null,
val title: String? = null,
val channels: List<String>? = null
)
@Serializable
data class SystemMessages(
val userJoined: String? = null,
val userLeft: String? = null,
val userKicked: String? = null,
val userBanned: String? = null
)
@Serializable
data class Role(
val name: String? = null,
val permissions: DefaultPermissions? = null,
val colour: String? = null,
val hoist: Boolean? = null,
val rank: Double? = null
)
@Serializable
data class DefaultPermissions(
val a: Long? = null,
val d: Long? = null
)
@Serializable
data class Emoji(
@SerialName("_id")
val id: String? = null,
val parent: EmojiParent? = null,
val creatorID: String? = null,
val name: String? = null,
val animated: Boolean? = null,
val type: String? = null, // this is _only_ used for websocket events!
)
@Serializable
data class EmojiParent(
val type: String? = null,
val id: String? = null
)

View File

@ -6,12 +6,11 @@ import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
@Serializable
data class CompleteUser(
data class User(
@SerialName("_id")
val id: String? = null,
val username: String? = null,
val avatar: Avatar? = null,
val avatar: AutumnResource? = null,
val relations: List<Relation>? = null,
val badges: Long? = null,
val status: Status? = null,
@ -23,42 +22,6 @@ data class CompleteUser(
val online: Boolean? = null
)
@Serializable
data class Avatar(
@SerialName("_id")
val id: String? = null,
val tag: String? = null,
val filename: String? = null,
val metadata: Metadata? = null,
@SerialName("content_type")
val contentType: String? = null,
val size: Long? = null,
val deleted: Boolean? = null,
val reported: Boolean? = null,
@SerialName("message_id")
val messageID: String? = null,
@SerialName("user_id")
val userID: String? = null,
@SerialName("server_id")
val serverID: String? = null,
@SerialName("object_id")
val objectID: String? = null
)
@Serializable
data class Metadata(
val type: String? = null,
val width: Long? = null,
val height: Long? = null
)
@Serializable
data class Bot(
val owner: String? = null
@ -67,7 +30,7 @@ data class Bot(
@Serializable
data class Profile(
val content: String? = null,
val background: Avatar? = null
val background: AutumnResource? = null
)
@Serializable

View File

@ -1,7 +1,9 @@
package chat.revolt.screens.chat
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Button
@ -55,7 +57,7 @@ class HomeScreenViewModel @Inject constructor(
fun sendMessage() {
viewModelScope.launch {
chat.revolt.api.routes.channel.sendMessage(
"01F7ZSBSFHCAAJQ92ZGTY67HMN",
"01F7ZSBSFHCAAJQ92ZGTY67HMN", // revolt lounge #general (temporarily hardcoded) FIXME
messageContent
)
}
@ -103,6 +105,28 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi
}
}
// a scrollable list of all users in user cache
Text(
text = "User cache",
style = MaterialTheme.typography.displaySmall.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Left,
fontSize = 24.sp
),
modifier = Modifier
.padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(),
)
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.height(200.dp)
) {
RevoltAPI.userCache.forEach { (_, user) ->
Text(text = user.username ?: user.id ?: "null")
}
}
Column() {
FormTextField(
value = viewModel.messageContent,

View File

@ -62,9 +62,8 @@ class GreeterViewModel @Inject constructor(
}
}
RevoltAPI.initialize()
if (RevoltAPI.isLoggedIn()) {
RevoltAPI.loginAs(token ?: "")
_skipLogin = true
}
@ -101,6 +100,7 @@ fun GreeterScreen(navController: NavController, viewModel: GreeterViewModel = hi
.height(60.dp)
)
}
return
}
Column(

View File

@ -21,9 +21,9 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.REVOLT_SUPPORT
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.account.EmailPasswordAssessment
import chat.revolt.api.routes.account.negotiateAuthentication
import chat.revolt.api.routes.user.fetchSelfWithNewToken
import chat.revolt.components.generic.AnyLink
import chat.revolt.components.generic.FormTextField
import chat.revolt.components.generic.Weblink
@ -76,7 +76,7 @@ class LoginViewModel @Inject constructor(
)
try {
fetchSelfWithNewToken(response.firstUserHints.token)
RevoltAPI.loginAs(response.firstUserHints.token)
kvStorage.set("sessionToken", response.firstUserHints.token)
_navigateTo = "home"

View File

@ -23,11 +23,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.account.MfaResponseRecoveryCode
import chat.revolt.api.routes.account.MfaResponseTotpCode
import chat.revolt.api.routes.account.authenticateWithMfaRecoveryCode
import chat.revolt.api.routes.account.authenticateWithMfaTotpCode
import chat.revolt.api.routes.user.fetchSelfWithNewToken
import chat.revolt.components.generic.CollapsibleCard
import chat.revolt.components.generic.FormTextField
import chat.revolt.persistence.KVStorage
@ -80,7 +80,7 @@ class MfaScreenViewModel @Inject constructor(
)
try {
fetchSelfWithNewToken(response.firstUserHints.token)
RevoltAPI.loginAs(response.firstUserHints.token)
kvStorage.set("sessionToken", response.firstUserHints.token)
_navigateToHome = true
@ -105,7 +105,7 @@ class MfaScreenViewModel @Inject constructor(
)
try {
fetchSelfWithNewToken(response.firstUserHints.token)
RevoltAPI.loginAs(response.firstUserHints.token)
kvStorage.set("sessionToken", response.firstUserHints.token)
_navigateToHome = true