feat: send messages

UI looks neater now
This commit is contained in:
Infi 2022-12-27 03:50:13 +01:00
parent b92d5369c1
commit 872889fefd
13 changed files with 444 additions and 97 deletions

View File

@ -99,6 +99,12 @@ object RevoltAPI {
Log.e("RevoltAPI", "WebSocket error", e)
}
RealtimeSocket.open = false
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()

View File

@ -135,8 +135,12 @@ object RealtimeSocket {
"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
val existing = RevoltAPI.userCache[userUpdateFrame.id]
?: return // if we don't have the user no point in updating it
RevoltAPI.userCache[userUpdateFrame.id] =
existing.mergeWithPartial(userUpdateFrame.data)
}
else -> {
Log.i("RealtimeSocket", "Unknown frame: $rawFrame")

View File

@ -28,4 +28,34 @@ suspend fun fetchSelf(): User {
RevoltAPI.selfId = user.id
return user
}
suspend fun fetchUser(id: String): User {
val response = RevoltHttp.get("/users/$id") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
}
.bodyAsText()
try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), response)
throw Error(error.type)
} catch (e: SerializationException) {
// Not an error
}
val user = RevoltJson.decodeFromString(User.serializer(), response)
RevoltAPI.userCache[user.id!!] = user
return user
}
suspend fun getOrFetchUser(id: String): User {
return RevoltAPI.userCache[id] ?: fetchUser(id)
}
suspend fun addUserIfUnknown(id: String) {
if (RevoltAPI.userCache[id] == null) {
RevoltAPI.userCache[id] = fetchUser(id)
}
}

View File

@ -27,9 +27,10 @@ data class Member(
@Serializable
data class Channel(
val channelType: ChannelType? = null,
@SerialName("_id")
val id: String? = null,
@SerialName("channel_type")
val channelType: ChannelType? = null,
val user: String? = null,
val name: String? = null,
val owner: String? = null,
@ -46,7 +47,28 @@ data class Channel(
val defaultPermissions: DefaultPermissions? = null,
val nsfw: Boolean? = null,
val type: String? = null, // this is _only_ used for websocket events!
)
) {
fun mergeWithPartial(partial: Channel): Channel {
return Channel(
channelType = partial.channelType ?: channelType,
id = partial.id ?: id,
user = partial.user ?: user,
name = partial.name ?: name,
owner = partial.owner ?: owner,
description = partial.description ?: description,
recipients = partial.recipients ?: recipients,
icon = partial.icon ?: icon,
lastMessageID = partial.lastMessageID ?: lastMessageID,
active = partial.active ?: active,
permissions = partial.permissions ?: permissions,
server = partial.server ?: server,
rolePermissions = partial.rolePermissions ?: rolePermissions,
defaultPermissions = partial.defaultPermissions ?: defaultPermissions,
nsfw = partial.nsfw ?: nsfw,
type = partial.type ?: type
)
}
}
@Serializable
enum class ChannelType(val value: String) {

View File

@ -23,6 +23,23 @@ data class Message(
fun getAuthor(): User? {
return author?.let { RevoltAPI.userCache[it] }
}
fun mergeWithPartial(partial: Message): Message {
return Message(
id = partial.id ?: id,
nonce = partial.nonce ?: nonce,
channel = partial.channel ?: channel,
author = partial.author ?: author,
content = partial.content ?: content,
reactions = partial.reactions ?: reactions,
replies = partial.replies ?: replies,
attachments = partial.attachments ?: attachments,
edited = partial.edited ?: edited,
embeds = partial.embeds ?: embeds,
mentions = partial.mentions ?: mentions,
type = partial.type ?: type
)
}
}
@Serializable

View File

@ -20,7 +20,24 @@ data class User(
val bot: Bot? = null,
val relationship: String? = null,
val online: Boolean? = null
)
) {
fun mergeWithPartial(partial: User): User {
return User(
id = partial.id ?: id,
username = partial.username ?: username,
avatar = partial.avatar ?: avatar,
relations = partial.relations ?: relations,
badges = partial.badges ?: badges,
status = partial.status ?: status,
profile = partial.profile ?: profile,
flags = partial.flags ?: flags,
privileged = partial.privileged ?: privileged,
bot = partial.bot ?: bot,
relationship = partial.relationship ?: relationship,
online = partial.online ?: online
)
}
}
@Serializable
data class Bot(

View File

@ -0,0 +1,60 @@
package chat.revolt.components.chat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.api.REVOLT_BASE
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.components.generic.RemoteImage
import chat.revolt.api.schemas.Message as MessageSchema
@Composable
fun Message(
message: MessageSchema
) {
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
Row() {
if (author.avatar != null) {
RemoteImage(
url = "$REVOLT_FILES/avatars/${author.avatar.id!!}/user.png",
modifier = Modifier
.size(50.dp)
.clip(CircleShape),
description = "Avatar for ${author.username}"
)
} else {
RemoteImage(
url = "$REVOLT_BASE/users/${author.id}/default_avatar",
modifier = Modifier
.size(50.dp)
.clip(CircleShape),
description = "Avatar for ${author.username}"
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
author.username?.let {
Text(
text = it,
fontWeight = FontWeight.Bold
)
}
message.content?.let {
Text(
text = it
)
}
}
}
}

View File

@ -0,0 +1,130 @@
package chat.revolt.components.chat
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.api.schemas.ChannelType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageField(
showButtons: Boolean,
onToggleButtons: (Boolean) -> Unit,
messageContent: String,
onMessageContentChange: (String) -> Unit,
onSendMessage: () -> Unit,
channelType: ChannelType,
channelName: String,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val placeholderResource = when (channelType) {
ChannelType.DirectMessage -> R.string.message_field_placeholder_dm
ChannelType.Group -> R.string.message_field_placeholder_group
ChannelType.TextChannel -> R.string.message_field_placeholder_text
ChannelType.VoiceChannel -> R.string.message_field_placeholder_voice
ChannelType.SavedMessages -> R.string.message_field_placeholder_notes
}
Row(modifier) {
// Additional buttons (currently adding an attachment)
AnimatedVisibility(visible = showButtons) {
Row(Modifier.weight(1f)) {
ElevatedButton(
onClick = {
Toast.makeText(
context,
"Placeholder for adding an attachment",
Toast.LENGTH_SHORT
).show()
},
modifier = Modifier.size(56.dp),
contentPadding = PaddingValues(0.dp),
shape = CircleShape
) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.add_attachment_alt)
)
}
}
}
// The small chevron you see when the buttons are hidden
AnimatedVisibility(visible = !showButtons) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.height(56.dp)
) {
Icon(
Icons.Default.KeyboardArrowLeft,
contentDescription = stringResource(id = R.string.show_more_alt),
modifier = Modifier
.size(24.dp + 8.dp)
.padding(vertical = 4.dp)
.clickable(onClick = {
onToggleButtons(true)
})
)
}
}
TextField(
value = messageContent,
onValueChange = onMessageContentChange,
singleLine = false,
shape = RoundedCornerShape(100),
placeholder = {
Text(
stringResource(
id = placeholderResource,
channelName
)
)
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
placeholderColor = Color.Gray,
),
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
)
// Send button (visible when text is entered)
AnimatedVisibility(visible = messageContent.isNotBlank()) {
Button(
onClick = onSendMessage,
modifier = Modifier
.size(56.dp),
contentPadding = PaddingValues(0.dp),
shape = CircleShape
) {
Icon(
Icons.Default.Send,
contentDescription = stringResource(id = R.string.send_alt)
)
}
}
}
}

View File

@ -1,20 +1,33 @@
package chat.revolt.screens.chat
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.api.RevoltAPI
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.routes.channel.sendMessage
import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.Message as MessageSchema
import chat.revolt.components.chat.Message
import kotlinx.coroutines.launch
import chat.revolt.R
import chat.revolt.components.chat.MessageField
class ChannelScreenViewModel : ViewModel() {
private var _channel by mutableStateOf<Channel?>(null)
@ -25,22 +38,59 @@ class ChannelScreenViewModel : ViewModel() {
val callbacks: RealtimeSocket.ChannelCallback?
get() = _callbacks.value
private var _renderableMessages = mutableStateListOf<Message>()
val renderableMessages: List<Message>
private var _renderableMessages = mutableStateListOf<MessageSchema>()
val renderableMessages: List<MessageSchema>
get() = _renderableMessages
private var _typingUsers = mutableStateListOf<String>()
val typingUsers: List<String>
get() = _typingUsers
private var _messageContent by mutableStateOf("")
val messageContent: String
get() = _messageContent
fun setMessageContent(content: String) {
_messageContent = content
if (content.isEmpty()) {
_showButtons = true
} else if (showButtons) {
_showButtons = false
}
}
private var _showButtons by mutableStateOf(true)
val showButtons: Boolean
get() = _showButtons
fun setShowButtons(show: Boolean) {
_showButtons = show
}
inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback {
override fun onMessage(message: MessageFrame) {
Log.d("ChannelScreen", "onMessage: $message")
viewModelScope.launch {
addUserIfUnknown(message.author!!)
}
_renderableMessages.add(message)
}
override fun onStartTyping(typing: ChannelStartTypingFrame) {
Log.d("ChannelScreen", "onStartTyping: $typing")
viewModelScope.launch {
addUserIfUnknown(typing.user)
}
if (!_typingUsers.contains(typing.user)) {
_typingUsers.add(typing.user)
}
}
override fun onStopTyping(typing: ChannelStopTypingFrame) {
Log.d("ChannelScreen", "onStopTyping: $typing")
if (_typingUsers.contains(typing.user)) {
_typingUsers.remove(typing.user)
}
}
}
@ -58,15 +108,40 @@ class ChannelScreenViewModel : ViewModel() {
registerCallback()
}
fun sendPendingMessage() {
viewModelScope.launch {
sendMessage(channel!!.id!!, messageContent)
}
_messageContent = ""
}
fun typingMessageResource(): Int {
return when (typingUsers.size) {
0 -> R.string.typing_blank
1 -> R.string.typing_one
in 2..4 -> R.string.typing_many
else -> R.string.typing_several
}
}
fun getTypingUsernames(): String {
return typingUsers.joinToString {
RevoltAPI.userCache[it]?.let { u ->
u.username ?: u.id
} ?: it
}
}
}
@Composable
fun ChannelScreen(
navController: NavController,
channelId: String,
viewModel: ChannelScreenViewModel = hiltViewModel()
viewModel: ChannelScreenViewModel = viewModel()
) {
val channel = viewModel.channel
val scrollState = rememberScrollState()
LaunchedEffect(channelId) {
viewModel.fetchChannel(channelId)
@ -86,10 +161,45 @@ fun ChannelScreen(
}
Column {
Text(text = channel.name!!)
viewModel.renderableMessages.forEach {
Text(text = "[" + it.getAuthor()!!.username + "] " + it.content!!)
Text(text = "#" + channel.name!!)
Divider()
// Column nesting is needed to make the vertical scroll work properly
Column(Modifier.weight(1f)) {
Column(Modifier.verticalScroll(scrollState)) {
viewModel.renderableMessages.forEach {
Message(message = it)
}
}
}
AnimatedVisibility(visible = viewModel.typingUsers.isNotEmpty()) {
Row(
Modifier
.padding(all = 4.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = stringResource(
id = viewModel.typingMessageResource(),
viewModel.getTypingUsernames()
)
)
}
}
channel.channelType?.let {
MessageField(
showButtons = viewModel.showButtons,
onToggleButtons = viewModel::setShowButtons,
messageContent = viewModel.messageContent,
onMessageContentChange = viewModel::setMessageContent,
onSendMessage = viewModel::sendPendingMessage,
channelType = it,
channelName = channel.name
)
}
}
}

View File

@ -2,15 +2,10 @@ 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.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -19,11 +14,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.components.generic.FormTextField
import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.screens.home.LinkOnHome
import chat.revolt.persistence.KVStorage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
@ -63,8 +54,6 @@ class HomeScreenViewModel @Inject constructor(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hiltViewModel()) {
val user = RevoltAPI.userCache[RevoltAPI.selfId]
val channelDrawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
@ -111,67 +100,7 @@ fun HomeScreen(navController: NavController, viewModel: HomeScreenViewModel = hi
.padding(horizontal = 15.dp, vertical = 15.dp)
.fillMaxWidth(),
)
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxSize()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
user?.let {
Row {
RemoteImage(
url = "${REVOLT_FILES}/avatars/${it.avatar?.id}/user.png",
modifier = Modifier
.size(50.dp)
.clip(CircleShape),
description = "Avatar for ${it.username} (placeholder!)"
)
Column(modifier = Modifier.padding(start = 10.dp)) {
it.username?.let { it1 -> Text(text = it1) }
it.id?.let { it1 -> Text(text = it1) }
}
}
}
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.height(200.dp)) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
RevoltAPI.userCache.forEach { (_, user) ->
Text(text = user.username ?: user.id ?: "null")
}
}
}
Column() {
FormTextField(
value = viewModel.messageContent,
label = "Content",
modifier = Modifier.fillMaxWidth(),
onChange = viewModel::setMessageContent
)
LinkOnHome(
heading = "Send",
icon = Icons.Filled.Send,
onClick = viewModel::sendMessage
)
}
}
Button(
onClick = {
viewModel.logout()

View File

@ -72,11 +72,11 @@ class LoginViewModel @Inject constructor(
} else {
Log.d(
"Login",
"No MFA required. Login is complete! We have a session token: ${response.firstUserHints!!.token}"
"No MFA required. Login is complete! We should have a session token"
)
try {
RevoltAPI.loginAs(response.firstUserHints.token)
RevoltAPI.loginAs(response.firstUserHints!!.token)
kvStorage.set("sessionToken", response.firstUserHints.token)
_navigateTo = "home"

View File

@ -76,11 +76,11 @@ class MfaScreenViewModel @Inject constructor(
} else {
Log.d(
"MFA",
"Successfully authorized TOTP. Token: ${response.firstUserHints!!.token}"
"Successfully authorized with TOTP."
)
try {
RevoltAPI.loginAs(response.firstUserHints.token)
RevoltAPI.loginAs(response.firstUserHints!!.token)
kvStorage.set("sessionToken", response.firstUserHints.token)
_navigateToHome = true
@ -101,11 +101,11 @@ class MfaScreenViewModel @Inject constructor(
} else {
Log.d(
"MFA",
"Successfully authorized recovery code. Token: ${response.firstUserHints!!.token}"
"Successfully authorized with a recovery code."
)
try {
RevoltAPI.loginAs(response.firstUserHints.token)
RevoltAPI.loginAs(response.firstUserHints!!.token)
kvStorage.set("sessionToken", response.firstUserHints.token)
_navigateToHome = true

View File

@ -61,4 +61,26 @@
<string name="comingsoon_heading">Gah, you found me!</string>
<string name="comingsoon_body">The feature you are trying to access is not ready yet, but we are steadily working on polishing it to perfection..</string>
<string name="typing_blank"><!-- this is a hack to prevent the typing indicator from showing typing_several when it's animating away --></string>
<string name="typing_one">%1$s is typing…</string>
<string name="typing_many">%1$s are typing…</string>
<string name="typing_several">Several people are typing</string>
<string name="send_alt">Send</string>
<string name="add_attachment_alt">Add attachment</string>
<string name="show_more_alt">Show more</string>
<string name="tutorial">Welcome to Revolt\'s in-progress Android experience!</string>
<string name="select_channel">Select a server and channel by swiping from the left.</string>
<string name="avatar_alt">%1$s\'s avatar</string>
<string name="message_field_placeholder_dm">Message @%1$s</string>
<string name="message_field_placeholder_text">Message #%1$s</string>
<string name="message_field_placeholder_voice">Message #%1$s</string>
<string name="message_field_placeholder_group">Message %1$s</string>
<string name="message_field_placeholder_notes">Add a note</string>
<string name="reply_message_not_cached">Unknown message, tap to jump</string>
</resources>