feat: use new ChannelCallbacks system, split off ChannelScreenViewModel
also add TimeRift component á la Mastodon for later use
This commit is contained in:
parent
a4fda034ff
commit
9f6d184d18
|
|
@ -1,4 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DesignSurface">
|
||||
<option name="filePathToZoomLevelMap">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue