feat: use new ChannelCallbacks system, split off ChannelScreenViewModel

also add TimeRift component á la Mastodon for later use
This commit is contained in:
Infi 2023-03-26 20:19:38 +02:00
parent a4fda034ff
commit 9f6d184d18
11 changed files with 726 additions and 396 deletions

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">

View File

@ -1,19 +1,34 @@
package chat.revolt.api.realtime
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateMap
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.receivable.AnyFrame
import chat.revolt.api.realtime.frames.receivable.BulkFrame
import chat.revolt.api.realtime.frames.receivable.ChannelAckFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelUpdateFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.realtime.frames.receivable.MessageUpdateFrame
import chat.revolt.api.realtime.frames.receivable.PongFrame
import chat.revolt.api.realtime.frames.receivable.ReadyFrame
import chat.revolt.api.realtime.frames.receivable.UserUpdateFrame
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 chat.revolt.callbacks.ChannelCallbacks
import io.ktor.client.plugins.websocket.ws
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.channels.consumeEach
import kotlinx.serialization.SerializationException
enum class DisconnectionState {
Disconnected,
@ -89,6 +104,7 @@ object RealtimeSocket {
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.")
@ -98,6 +114,7 @@ object RealtimeSocket {
handleFrame(subFrameType, subFrame.toString())
}
}
"Ready" -> {
val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame)
Log.d(
@ -125,6 +142,7 @@ object RealtimeSocket {
RevoltAPI.emojiCache[emoji.id!!] = emoji
}
}
"Message" -> {
val messageFrame = RevoltJson.decodeFromString(MessageFrame.serializer(), rawFrame)
Log.d(
@ -132,16 +150,82 @@ object RealtimeSocket {
"Received message frame for ${messageFrame.id} in channel ${messageFrame.channel}."
)
RevoltAPI.messageCache[messageFrame.id!!] = messageFrame
// Update last message ID for channel - important for unreads
messageFrame.channel?.let {
RevoltAPI.channelCache[it] =
RevoltAPI.channelCache[it]!!.copy(lastMessageID = messageFrame.id)
if (messageFrame.id == null) {
Log.d("RealtimeSocket", "Message frame has no ID or channel. Ignoring.")
return
}
channelCallbacks[messageFrame.channel]?.onMessage(messageFrame)
RevoltAPI.messageCache[messageFrame.id] = messageFrame
messageFrame.channel?.let {
if (RevoltAPI.channelCache[it] == null) {
Log.d("RealtimeSocket", "Channel $it not found in cache. Ignoring.")
return
}
RevoltAPI.channelCache[it] =
RevoltAPI.channelCache[it]!!.copy(lastMessageID = messageFrame.id)
ChannelCallbacks.emitMessage(it, messageFrame.id)
}
}
"MessageUpdate" -> {
val messageUpdateFrame =
RevoltJson.decodeFromString(MessageUpdateFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received message update frame for ${messageUpdateFrame.id} in channel ${messageUpdateFrame.channel}."
)
val oldMessage = RevoltAPI.messageCache[messageUpdateFrame.id]
if (oldMessage == null) {
Log.d(
"RealtimeSocket",
"Message ${messageUpdateFrame.id} not found in cache. Will not update."
)
return
}
val rawMessage: MessageFrame
try {
rawMessage =
RevoltJson.decodeFromJsonElement(
MessageFrame.serializer(),
messageUpdateFrame.data
)
} catch (e: SerializationException) {
Log.d("RealtimeSocket", "Message update frame has invalid data. Ignoring.")
return
}
Log.d(
"RealtimeSocket",
"Merging message ${messageUpdateFrame.id} with partial message: $rawMessage"
)
Log.d(
"RealtimeSocket",
"Old: $oldMessage"
)
RevoltAPI.messageCache[messageUpdateFrame.id] =
oldMessage.mergeWithPartial(rawMessage)
Log.d(
"RealtimeSocket",
"New: ${RevoltAPI.messageCache[messageUpdateFrame.id]}"
)
messageUpdateFrame.channel.let {
if (RevoltAPI.channelCache[it] == null) {
Log.d("RealtimeSocket", "Channel $it not found in cache. Ignoring.")
return
}
ChannelCallbacks.emitMessageUpdate(it, messageUpdateFrame.id)
}
}
"ChannelStartTyping" -> {
val typingFrame =
RevoltJson.decodeFromString(ChannelStartTypingFrame.serializer(), rawFrame)
@ -150,8 +234,9 @@ object RealtimeSocket {
"Received channel start typing frame for ${typingFrame.id} from ${typingFrame.user}."
)
channelCallbacks[typingFrame.id]?.onStartTyping(typingFrame)
ChannelCallbacks.emitStartTyping(typingFrame.id, typingFrame.user)
}
"ChannelStopTyping" -> {
val typingFrame =
RevoltJson.decodeFromString(ChannelStopTypingFrame.serializer(), rawFrame)
@ -160,8 +245,9 @@ object RealtimeSocket {
"Received channel stop typing frame for ${typingFrame.id} from ${typingFrame.user}."
)
channelCallbacks[typingFrame.id]?.onStopTyping(typingFrame)
ChannelCallbacks.emitStopTyping(typingFrame.id, typingFrame.user)
}
"UserUpdate" -> {
val userUpdateFrame =
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)
@ -172,6 +258,7 @@ object RealtimeSocket {
RevoltAPI.userCache[userUpdateFrame.id] =
existing.mergeWithPartial(userUpdateFrame.data)
}
"ChannelUpdate" -> {
val channelUpdateFrame =
RevoltJson.decodeFromString(ChannelUpdateFrame.serializer(), rawFrame)
@ -182,6 +269,7 @@ object RealtimeSocket {
RevoltAPI.channelCache[channelUpdateFrame.id] =
existing.mergeWithPartial(channelUpdateFrame.data)
}
"ChannelAck" -> {
val channelAckFrame =
RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame)
@ -192,9 +280,11 @@ object RealtimeSocket {
RevoltAPI.unreads.processExternalAck(channelAckFrame.id, channelAckFrame.messageId)
}
"Authenticated" -> {
// No effect
}
else -> {
Log.i("RealtimeSocket", "Unknown frame: $rawFrame")
}
@ -202,12 +292,10 @@ object RealtimeSocket {
}
private fun invalidateAllChannelStates() {
channelCallbacks.forEach { (_, callback) ->
callback.onStateInvalidate()
}
ChannelCallbacks.emitReconnect()
}
interface ChannelCallback {
/*interface ChannelCallback {
fun onStartTyping(typing: ChannelStartTypingFrame)
fun onStopTyping(typing: ChannelStopTypingFrame)
fun onMessage(message: MessageFrame)
@ -226,5 +314,5 @@ object RealtimeSocket {
channelCallbacks.remove(channelId)
Log.d("RealtimeSocket", "Unregistered channel callback for $channelId")
}
}*/
}

View File

@ -0,0 +1,76 @@
package chat.revolt.callbacks
object ChannelCallbacks {
interface CallbackReceiver {
fun onReconnect()
fun onStartTyping(channelId: String, userId: String)
fun onStopTyping(channelId: String, userId: String)
fun onMessage(messageId: String)
fun onMessageUpdate(messageId: String)
fun onMessageDelete(messageId: String)
fun onMessageBulkDelete(messageIds: List<String>)
fun onMessageReactionAdd(messageId: String, emoji: String, userId: String)
fun onMessageReactionRemove(messageId: String, emoji: String, userId: String)
fun onMessageReactionRemoveAll(messageId: String)
}
var receivers = mutableMapOf<String, CallbackReceiver>()
fun registerReceiver(channelId: String, receiver: CallbackReceiver) {
receivers[channelId] = receiver
}
fun unregisterReceiver(channelId: String) {
receivers.remove(channelId)
}
fun emitReconnect() {
receivers.forEach { it.value.onReconnect() }
}
fun emitStartTyping(channelId: String, userId: String) {
receivers[channelId]?.onStartTyping(channelId, userId)
}
fun emitStopTyping(channelId: String, userId: String) {
receivers[channelId]?.onStopTyping(channelId, userId)
}
fun emitMessage(channelId: String, messageId: String) {
receivers[channelId]?.onMessage(messageId)
}
fun emitMessageUpdate(channelId: String, messageId: String) {
receivers[channelId]?.onMessageUpdate(messageId)
}
fun emitMessageDelete(channelId: String, messageId: String) {
receivers[channelId]?.onMessageDelete(messageId)
}
fun emitMessageBulkDelete(channelId: String, messageIds: List<String>) {
receivers[channelId]?.onMessageBulkDelete(messageIds)
}
fun emitMessageReactionAdd(
channelId: String,
messageId: String,
emoji: String,
userId: String
) {
receivers[channelId]?.onMessageReactionAdd(messageId, emoji, userId)
}
fun emitMessageReactionRemove(
channelId: String,
messageId: String,
emoji: String,
userId: String
) {
receivers[channelId]?.onMessageReactionRemove(messageId, emoji, userId)
}
fun emitMessageReactionRemoveAll(channelId: String, messageId: String) {
receivers[channelId]?.onMessageReactionRemoveAll(messageId)
}
}

View File

@ -147,7 +147,7 @@ fun Message(
if (message.content.isBlank()) return@let // if only an attachment is sent
AndroidView(factory = { ctx ->
android.widget.TextView(ctx).apply {
androidx.appcompat.widget.AppCompatTextView(ctx).apply {
text = parse(message)
maxLines = if (truncate) 1 else Int.MAX_VALUE
ellipsize = TextUtils.TruncateAt.END

View File

@ -0,0 +1,71 @@
package chat.revolt.components.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalContentColor
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.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 TimeRift(
modifier: Modifier = Modifier,
onMessageLoad: () -> Unit,
) {
Column(
modifier = modifier
.padding(vertical = 10.dp)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.fillMaxWidth()
.padding(vertical = 20.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(id = R.string.time_rift_heading),
color = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.9f
),
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Normal,
fontSize = 15.sp
),
modifier = Modifier
.padding(horizontal = 2.5.dp, vertical = 3.dp)
)
Spacer(modifier = Modifier.height(5.dp))
TextButton(onClick = onMessageLoad) {
Text(
text = stringResource(id = R.string.time_rift_cta),
color = LocalContentColor.current.copy(
alpha = 0.9f
),
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Normal,
fontSize = 15.sp
),
modifier = Modifier
.padding(horizontal = 2.5.dp, vertical = 3.dp)
)
}
}
}

View File

@ -90,7 +90,7 @@ fun UIMarkdown(
AndroidView(
factory = {
android.widget.TextView(it).apply {
androidx.appcompat.widget.AppCompatTextView(it).apply {
ellipsize = TextUtils.TruncateAt.END
typeface = ResourcesCompat.getFont(it, R.font.inter)

View File

@ -62,9 +62,9 @@ import chat.revolt.screens.chat.sheets.ChannelContextSheet
import chat.revolt.screens.chat.sheets.ChannelInfoSheet
import chat.revolt.screens.chat.sheets.MessageContextSheet
import chat.revolt.screens.chat.sheets.StatusSheet
import chat.revolt.screens.chat.views.ChannelScreen
import chat.revolt.screens.chat.views.HomeScreen
import chat.revolt.screens.chat.views.NoCurrentChannelScreen
import chat.revolt.screens.chat.views.channel.ChannelScreen
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.google.accompanist.navigation.material.ModalBottomSheetLayout
import com.google.accompanist.navigation.material.bottomSheet

View File

@ -1,6 +1,5 @@
package chat.revolt.screens.chat.views
package chat.revolt.screens.chat.views.channel
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
@ -26,8 +25,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import chat.revolt.R
@ -35,21 +32,8 @@ import chat.revolt.RevoltTweenDp
import chat.revolt.RevoltTweenFloat
import chat.revolt.RevoltTweenInt
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID
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.SendMessageReply
import chat.revolt.api.routes.channel.ackChannel
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.api.routes.channel.fetchSingleChannel
import chat.revolt.api.routes.channel.sendMessage
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.schemas.Channel
import chat.revolt.callbacks.ChannelCallbacks
import chat.revolt.callbacks.UiCallbacks
import chat.revolt.components.chat.Message
import chat.revolt.components.chat.MessageField
@ -68,362 +52,9 @@ import chat.revolt.internals.markdown.createInlineCodeRule
import com.discord.simpleast.core.simple.SimpleMarkdownRules
import com.discord.simpleast.core.simple.SimpleRenderer
import io.ktor.http.*
import io.sentry.Sentry
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import java.io.File
import chat.revolt.api.schemas.Message as MessageSchema
class ChannelScreenViewModel : ViewModel() {
private var _channel by mutableStateOf<Channel?>(null)
val channel: Channel?
get() = _channel
private var _uiCallbackRegistered by mutableStateOf(false)
private var _channelCallback = mutableStateOf<RealtimeSocket.ChannelCallback?>(null)
private val channelCallback: RealtimeSocket.ChannelCallback?
get() = _channelCallback.value
private var _renderableMessages = mutableStateListOf<MessageSchema>()
val renderableMessages: List<MessageSchema>
get() = _renderableMessages
private fun setRenderableMessages(messages: List<MessageSchema>) {
_renderableMessages.clear()
_renderableMessages.addAll(messages)
}
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
}
private var _attachments = mutableStateListOf<FileArgs>()
val attachments: List<FileArgs>
get() = _attachments
private fun setAttachments(attachments: List<FileArgs>) {
_attachments.clear()
_attachments.addAll(attachments)
}
fun addAttachment(fileArgs: FileArgs) {
_attachments.add(fileArgs)
}
fun removeAttachment(fileArgs: FileArgs) {
_attachments.remove(fileArgs)
}
private fun popAttachmentBatch() {
setAttachments(_attachments.drop(MAX_ATTACHMENTS_PER_MESSAGE))
}
private var _sendingMessage by mutableStateOf(false)
val sendingMessage: Boolean
get() = _sendingMessage
private fun setSendingMessage(sending: Boolean) {
_sendingMessage = sending
}
private var _replies = mutableStateListOf<SendMessageReply>()
val replies: List<SendMessageReply>
get() = _replies
fun addInReplyTo(reply: SendMessageReply) {
_replies.add(reply)
}
fun removeReply(reply: SendMessageReply) {
_replies.remove(reply)
}
fun toggleReplyMentionFor(reply: SendMessageReply) {
val index = _replies.indexOf(reply)
val newReply = SendMessageReply(
reply.id,
!reply.mention
)
_replies[index] = newReply
}
private fun clearInReplyTo() {
_replies.clear()
}
private var _noMoreMessages by mutableStateOf(false)
val noMoreMessages: Boolean
get() = _noMoreMessages
private fun setNoMoreMessages(noMore: Boolean) {
_noMoreMessages = noMore
}
private var _uiCallbackReceiver = mutableStateOf<UiCallbacks.CallbackReceiver?>(null)
val uiCallbackReceiver: UiCallbacks.CallbackReceiver?
get() = _uiCallbackReceiver.value
inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback {
override fun onMessage(message: MessageFrame) {
viewModelScope.launch {
addUserIfUnknown(message.author!!)
}
regroupMessages(listOf(message) + renderableMessages)
ackNewest()
}
override fun onStartTyping(typing: ChannelStartTypingFrame) {
viewModelScope.launch {
addUserIfUnknown(typing.user)
}
if (!_typingUsers.contains(typing.user)) {
_typingUsers.add(typing.user)
}
}
override fun onStopTyping(typing: ChannelStopTypingFrame) {
if (_typingUsers.contains(typing.user)) {
_typingUsers.remove(typing.user)
}
}
override fun onStateInvalidate() {
fetchMessages()
_typingUsers.clear()
}
}
inner class UiCallbackReceiver : UiCallbacks.CallbackReceiver {
override fun onQueueMessageForReply(messageId: String) {
viewModelScope.launch {
addInReplyTo(SendMessageReply(messageId, true))
}
}
}
private fun registerCallbacks() {
if (channel?.id == null) {
Sentry.captureException(IllegalStateException("Channel ID is null while trying to register callbacks"))
return
}
_channelCallback.value = ChannelScreenCallback()
RealtimeSocket.registerChannelCallback(channel!!.id!!, channelCallback!!)
if (!_uiCallbackRegistered) {
_uiCallbackReceiver.value = UiCallbackReceiver()
UiCallbacks.registerReceiver(uiCallbackReceiver!!)
_uiCallbackRegistered = true
} else {
Log.d(
"ChannelScreenViewModel",
"UI Callbacks already registered but trying to register again. Ignoring but this is a bug."
)
}
}
fun fetchMessages() {
if (channel == null) {
return
}
_renderableMessages.clear()
viewModelScope.launch {
val messages = arrayListOf<MessageSchema>()
fetchMessagesFromChannel(channel!!.id!!, limit = 50, false).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
setNoMoreMessages(true)
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
}
regroupMessages(renderableMessages + messages)
}
}
fun fetchOlderMessages() {
if (channel == null) {
return
}
viewModelScope.launch {
val messages = arrayListOf<MessageSchema>()
if (renderableMessages.isNotEmpty()) {
fetchMessagesFromChannel(
channel!!.id!!,
limit = 50,
true,
before = renderableMessages.last().id
).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
setNoMoreMessages(true)
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
}
} else {
fetchMessagesFromChannel(channel!!.id!!, limit = 50, true).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
setNoMoreMessages(true)
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
}
}
regroupMessages(renderableMessages + messages)
}
}
fun fetchChannel(id: String) {
viewModelScope.launch {
if (id !in RevoltAPI.channelCache) {
val channel = fetchSingleChannel(id)
_channel = channel
RevoltAPI.channelCache[id] = channel
} else {
_channel = RevoltAPI.channelCache[id]
}
registerCallbacks()
if (_channel?.lastMessageID != null) {
ackNewest()
} else {
Log.d("ChannelScreen", "No last message ID, not acking.")
}
}
}
fun sendPendingMessage() {
setSendingMessage(true)
viewModelScope.launch {
val attachmentIds = arrayListOf<String>()
attachments.take(MAX_ATTACHMENTS_PER_MESSAGE).forEach {
try {
val id = uploadToAutumn(
it.file,
it.filename,
"attachments",
ContentType.parse(it.contentType)
)
Log.d("ChannelScreen", "Uploaded attachment with id $id")
attachmentIds.add(id)
} catch (e: Exception) {
Log.e("ChannelScreen", "Failed to upload attachment", e)
return@launch
}
}
sendMessage(
channel!!.id!!,
messageContent,
attachments = if (attachmentIds.isEmpty()) null else attachmentIds,
replies = replies
)
_messageContent = ""
popAttachmentBatch()
clearInReplyTo()
setSendingMessage(false)
}
}
private fun regroupMessages(newMessages: List<MessageSchema> = renderableMessages) {
val groupedMessages = mutableMapOf<String, MessageSchema>()
// Verbatim implementation of https://wiki.rvlt.gg/index.php/Text_Channel_(UI)#Message_Grouping_Algorithm
// The exception is the date variable being pushed into cache, we don't need that here.
// Keep in mind: Recomposing UI is incredibly cheap in Jetpack Compose.
newMessages.forEach { message ->
var tail = true
val next = newMessages.getOrNull(newMessages.indexOf(message) + 1)
if (next != null) {
val dateA = Instant.fromEpochMilliseconds(ULID.asTimestamp(message.id!!))
val dateB = Instant.fromEpochMilliseconds(ULID.asTimestamp(next.id!!))
val minuteDifference = (dateA - dateB).inWholeMinutes
if (
message.author != next.author ||
minuteDifference >= 7 ||
message.masquerade != next.masquerade ||
message.system != null || next.system != null ||
message.replies != null
) {
tail = false
}
} else {
tail = false
}
if (groupedMessages.containsKey(message.id!!)) return@forEach
groupedMessages[message.id] = message.copy(tail = tail)
}
setRenderableMessages(groupedMessages.values.toList())
}
private var debouncedChannelAck: Job? = null
private fun ackNewest() {
if (debouncedChannelAck?.isActive == true) {
debouncedChannelAck?.cancel()
Log.d("ChannelScreen", "Cancelling channel ack")
}
if (channel?.lastMessageID == null) return
RevoltAPI.unreads.processExternalAck(channel!!.id!!, channel!!.lastMessageID!!)
debouncedChannelAck = viewModelScope.launch {
delay(1000)
if (channel?.lastMessageID == null) return@launch
ackChannel(channel!!.id!!, channel!!.lastMessageID!!)
Log.d("ChannelScreen", "Acking channel")
}
}
}
@Composable
fun ChannelScreen(
@ -481,7 +112,7 @@ fun ChannelScreen(
DisposableEffect(channelId) {
onDispose {
RealtimeSocket.unregisterChannelCallback(channelId)
viewModel.channelCallbackReceiver?.let { ChannelCallbacks.unregisterReceiver(channelId) }
viewModel.uiCallbackReceiver?.let { UiCallbacks.unregisterReceiver(it) }
}
}

View File

@ -0,0 +1,463 @@
package chat.revolt.screens.chat.views.channel
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID
import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.api.routes.channel.ackChannel
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.api.routes.channel.fetchSingleChannel
import chat.revolt.api.routes.channel.sendMessage
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message
import chat.revolt.callbacks.ChannelCallbacks
import chat.revolt.callbacks.UiCallbacks
import io.ktor.http.ContentType
import io.sentry.Sentry
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
class ChannelScreenViewModel : ViewModel() {
private var _channel by mutableStateOf<Channel?>(null)
val channel: Channel?
get() = _channel
private var _renderableMessages = mutableStateListOf<Message>()
val renderableMessages: List<Message>
get() = _renderableMessages
private fun setRenderableMessages(messages: List<Message>) {
_renderableMessages.clear()
_renderableMessages.addAll(messages)
}
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
}
private var _attachments = mutableStateListOf<FileArgs>()
val attachments: List<FileArgs>
get() = _attachments
private fun setAttachments(attachments: List<FileArgs>) {
_attachments.clear()
_attachments.addAll(attachments)
}
fun addAttachment(fileArgs: FileArgs) {
_attachments.add(fileArgs)
}
fun removeAttachment(fileArgs: FileArgs) {
_attachments.remove(fileArgs)
}
private fun popAttachmentBatch() {
setAttachments(_attachments.drop(MAX_ATTACHMENTS_PER_MESSAGE))
}
private var _sendingMessage by mutableStateOf(false)
val sendingMessage: Boolean
get() = _sendingMessage
private fun setSendingMessage(sending: Boolean) {
_sendingMessage = sending
}
private var _replies = mutableStateListOf<SendMessageReply>()
val replies: List<SendMessageReply>
get() = _replies
fun addInReplyTo(reply: SendMessageReply) {
_replies.add(reply)
}
fun removeReply(reply: SendMessageReply) {
_replies.remove(reply)
}
fun toggleReplyMentionFor(reply: SendMessageReply) {
val index = _replies.indexOf(reply)
val newReply = SendMessageReply(
reply.id,
!reply.mention
)
_replies[index] = newReply
}
private fun clearInReplyTo() {
_replies.clear()
}
private var _noMoreMessages by mutableStateOf(false)
val noMoreMessages: Boolean
get() = _noMoreMessages
private fun setNoMoreMessages(noMore: Boolean) {
_noMoreMessages = noMore
}
private var _uiCallbackReceiver = mutableStateOf<UiCallbacks.CallbackReceiver?>(null)
val uiCallbackReceiver: UiCallbacks.CallbackReceiver?
get() = _uiCallbackReceiver.value
private var _uiCallbackRegistered by mutableStateOf(false)
private var _channelCallbackReceiver = mutableStateOf<ChannelCallbacks.CallbackReceiver?>(null)
val channelCallbackReceiver: ChannelCallbacks.CallbackReceiver?
get() = _channelCallbackReceiver.value
private var _channelCallbackRegistered by mutableStateOf(false)
/*
inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback {
override fun onMessage(message: Message) {
viewModelScope.launch {
addUserIfUnknown(message.author!!)
}
regroupMessages(listOf(message) + renderableMessages)
ackNewest()
}
override fun onStartTyping(typing: ChannelStartTypingFrame) {
viewModelScope.launch {
addUserIfUnknown(typing.user)
}
if (!_typingUsers.contains(typing.user)) {
_typingUsers.add(typing.user)
}
}
override fun onStopTyping(typing: ChannelStopTypingFrame) {
if (_typingUsers.contains(typing.user)) {
_typingUsers.remove(typing.user)
}
}
override fun onStateInvalidate() {
fetchMessages()
_typingUsers.clear()
}
}*/
inner class UiCallbackReceiver : UiCallbacks.CallbackReceiver {
override fun onQueueMessageForReply(messageId: String) {
viewModelScope.launch {
addInReplyTo(SendMessageReply(messageId, true))
}
}
}
inner class ChannelCallbackReceiver : ChannelCallbacks.CallbackReceiver {
override fun onReconnect() {
fetchMessages()
_typingUsers.clear()
// TODO push time rift to messages
}
override fun onStartTyping(channelId: String, userId: String) {
viewModelScope.launch {
addUserIfUnknown(userId)
if (!_typingUsers.contains(userId)) {
_typingUsers.add(userId)
}
}
}
override fun onStopTyping(channelId: String, userId: String) {
if (_typingUsers.contains(userId)) {
_typingUsers.remove(userId)
}
}
override fun onMessage(messageId: String) {
viewModelScope.launch {
val message = RevoltAPI.messageCache[messageId] ?: return@launch
addUserIfUnknown(message.author!!)
regroupMessages(listOf(message) + renderableMessages)
ackNewest()
}
}
override fun onMessageUpdate(messageId: String) {
val message = RevoltAPI.messageCache[messageId] ?: return
Log.d("ChannelScreen", "Handler Message updated: $message")
regroupMessages(renderableMessages.map {
if (it.id == message.id) {
message
} else {
it
}
})
}
override fun onMessageDelete(messageId: String) {
// TODO Not implemented
Log.d("ChannelScreen", "Handler Message deleted: $messageId")
}
override fun onMessageBulkDelete(messageIds: List<String>) {
// TODO Not implemented
Log.d("ChannelScreen", "Handler Messages bulk deleted: $messageIds")
}
override fun onMessageReactionAdd(messageId: String, emoji: String, userId: String) {
// TODO Not implemented
Log.d("ChannelScreen", "Handler Message reaction added: $messageId $emoji $userId")
}
override fun onMessageReactionRemove(messageId: String, emoji: String, userId: String) {
// TODO Not implemented
Log.d("ChannelScreen", "Handler Message reaction removed: $messageId $emoji $userId")
}
override fun onMessageReactionRemoveAll(messageId: String) {
// TODO Not implemented
Log.d("ChannelScreen", "Handler Message reactions removed: $messageId")
}
}
private fun registerCallbacks() {
if (channel?.id == null) {
Sentry.captureException(IllegalStateException("Channel ID is null while trying to register callbacks"))
return
}
if (!_channelCallbackRegistered) {
_channelCallbackReceiver.value = ChannelCallbackReceiver()
ChannelCallbacks.registerReceiver(channel!!.id!!, _channelCallbackReceiver.value!!)
_channelCallbackRegistered = true
} else {
Log.d(
"ChannelScreenViewModel",
"Channel Callbacks already registered but trying to register again. Ignoring but this is a bug."
)
}
if (!_uiCallbackRegistered) {
_uiCallbackReceiver.value = UiCallbackReceiver()
UiCallbacks.registerReceiver(_uiCallbackReceiver.value!!)
_uiCallbackRegistered = true
} else {
Log.d(
"ChannelScreenViewModel",
"UI Callbacks already registered but trying to register again. Ignoring but this is a bug."
)
}
}
fun fetchMessages() {
if (channel == null) {
return
}
_renderableMessages.clear()
viewModelScope.launch {
val messages = arrayListOf<Message>()
fetchMessagesFromChannel(channel!!.id!!, limit = 50, false).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
setNoMoreMessages(true)
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
}
regroupMessages(renderableMessages + messages)
}
}
fun fetchOlderMessages() {
if (channel == null) {
return
}
viewModelScope.launch {
val messages = arrayListOf<Message>()
if (renderableMessages.isNotEmpty()) {
fetchMessagesFromChannel(
channel!!.id!!,
limit = 50,
true,
before = renderableMessages.last().id
).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
setNoMoreMessages(true)
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
}
} else {
fetchMessagesFromChannel(channel!!.id!!, limit = 50, true).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
setNoMoreMessages(true)
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
}
}
regroupMessages(renderableMessages + messages)
}
}
fun fetchChannel(id: String) {
viewModelScope.launch {
if (id !in RevoltAPI.channelCache) {
val channel = fetchSingleChannel(id)
_channel = channel
RevoltAPI.channelCache[id] = channel
} else {
_channel = RevoltAPI.channelCache[id]
}
registerCallbacks()
if (_channel?.lastMessageID != null) {
ackNewest()
} else {
Log.d("ChannelScreen", "No last message ID, not acking.")
}
}
}
fun sendPendingMessage() {
setSendingMessage(true)
viewModelScope.launch {
val attachmentIds = arrayListOf<String>()
attachments.take(MAX_ATTACHMENTS_PER_MESSAGE).forEach {
try {
val id = uploadToAutumn(
it.file,
it.filename,
"attachments",
ContentType.parse(it.contentType)
)
Log.d("ChannelScreen", "Uploaded attachment with id $id")
attachmentIds.add(id)
} catch (e: Exception) {
Log.e("ChannelScreen", "Failed to upload attachment", e)
return@launch
}
}
sendMessage(
channel!!.id!!,
messageContent,
attachments = if (attachmentIds.isEmpty()) null else attachmentIds,
replies = replies
)
_messageContent = ""
popAttachmentBatch()
clearInReplyTo()
setSendingMessage(false)
}
}
private fun regroupMessages(newMessages: List<Message> = renderableMessages) {
val groupedMessages = mutableMapOf<String, Message>()
// Verbatim implementation of https://wiki.rvlt.gg/index.php/Text_Channel_(UI)#Message_Grouping_Algorithm
// The exception is the date variable being pushed into cache, we don't need that here.
// Keep in mind: Recomposing UI is incredibly cheap in Jetpack Compose.
newMessages.forEach { message ->
var tail = true
val next = newMessages.getOrNull(newMessages.indexOf(message) + 1)
if (next != null) {
val dateA = Instant.fromEpochMilliseconds(ULID.asTimestamp(message.id!!))
val dateB = Instant.fromEpochMilliseconds(ULID.asTimestamp(next.id!!))
val minuteDifference = (dateA - dateB).inWholeMinutes
if (
message.author != next.author ||
minuteDifference >= 7 ||
message.masquerade != next.masquerade ||
message.system != null || next.system != null ||
message.replies != null
) {
tail = false
}
} else {
tail = false
}
if (groupedMessages.containsKey(message.id!!)) return@forEach
groupedMessages[message.id] = message.copy(tail = tail)
}
setRenderableMessages(groupedMessages.values.toList())
}
private var debouncedChannelAck: Job? = null
private fun ackNewest() {
if (debouncedChannelAck?.isActive == true) {
debouncedChannelAck?.cancel()
Log.d("ChannelScreen", "Cancelling channel ack")
}
if (channel?.lastMessageID == null) return
RevoltAPI.unreads.processExternalAck(channel!!.id!!, channel!!.lastMessageID!!)
debouncedChannelAck = viewModelScope.launch {
delay(1000)
if (channel?.lastMessageID == null) return@launch
ackChannel(channel!!.id!!, channel!!.lastMessageID!!)
Log.d("ChannelScreen", "Acking channel")
}
}
}

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Revolt" parent="android:Theme.Material.Light.NoActionBar">
<style name="Theme.Revolt" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowSplashScreenBackground">@color/background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
</style>

View File

@ -103,6 +103,8 @@
<string name="channel_notes">Notes</string>
<string name="start_of_conversation">This is the start of your conversation</string>
<string name="time_rift_heading">There may be some messages missing here.</string>
<string name="time_rift_cta">Load messages</string>
<string name="message_field_placeholder_dm">Message @%1$s</string>
<string name="message_field_placeholder_text">Message #%1$s</string>