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">
|
<project version="4">
|
||||||
<component name="DesignSurface">
|
<component name="DesignSurface">
|
||||||
<option name="filePathToZoomLevelMap">
|
<option name="filePathToZoomLevelMap">
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
package chat.revolt.api.realtime
|
package chat.revolt.api.realtime
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
|
||||||
import chat.revolt.api.REVOLT_WEBSOCKET
|
import chat.revolt.api.REVOLT_WEBSOCKET
|
||||||
import chat.revolt.api.RevoltAPI
|
import chat.revolt.api.RevoltAPI
|
||||||
import chat.revolt.api.RevoltHttp
|
import chat.revolt.api.RevoltHttp
|
||||||
import chat.revolt.api.RevoltJson
|
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.AuthorizationFrame
|
||||||
import chat.revolt.api.realtime.frames.sendable.PingFrame
|
import chat.revolt.api.realtime.frames.sendable.PingFrame
|
||||||
import io.ktor.client.plugins.websocket.*
|
import chat.revolt.callbacks.ChannelCallbacks
|
||||||
import io.ktor.websocket.*
|
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.coroutines.channels.consumeEach
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
|
||||||
enum class DisconnectionState {
|
enum class DisconnectionState {
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
|
@ -89,6 +104,7 @@ object RealtimeSocket {
|
||||||
val pongFrame = RevoltJson.decodeFromString(PongFrame.serializer(), rawFrame)
|
val pongFrame = RevoltJson.decodeFromString(PongFrame.serializer(), rawFrame)
|
||||||
Log.d("RealtimeSocket", "Received pong frame for ${pongFrame.data}")
|
Log.d("RealtimeSocket", "Received pong frame for ${pongFrame.data}")
|
||||||
}
|
}
|
||||||
|
|
||||||
"Bulk" -> {
|
"Bulk" -> {
|
||||||
val bulkFrame = RevoltJson.decodeFromString(BulkFrame.serializer(), rawFrame)
|
val bulkFrame = RevoltJson.decodeFromString(BulkFrame.serializer(), rawFrame)
|
||||||
Log.d("RealtimeSocket", "Received bulk frame with ${bulkFrame.v.size} sub-frames.")
|
Log.d("RealtimeSocket", "Received bulk frame with ${bulkFrame.v.size} sub-frames.")
|
||||||
|
|
@ -98,6 +114,7 @@ object RealtimeSocket {
|
||||||
handleFrame(subFrameType, subFrame.toString())
|
handleFrame(subFrameType, subFrame.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"Ready" -> {
|
"Ready" -> {
|
||||||
val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame)
|
val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame)
|
||||||
Log.d(
|
Log.d(
|
||||||
|
|
@ -125,6 +142,7 @@ object RealtimeSocket {
|
||||||
RevoltAPI.emojiCache[emoji.id!!] = emoji
|
RevoltAPI.emojiCache[emoji.id!!] = emoji
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"Message" -> {
|
"Message" -> {
|
||||||
val messageFrame = RevoltJson.decodeFromString(MessageFrame.serializer(), rawFrame)
|
val messageFrame = RevoltJson.decodeFromString(MessageFrame.serializer(), rawFrame)
|
||||||
Log.d(
|
Log.d(
|
||||||
|
|
@ -132,16 +150,82 @@ object RealtimeSocket {
|
||||||
"Received message frame for ${messageFrame.id} in channel ${messageFrame.channel}."
|
"Received message frame for ${messageFrame.id} in channel ${messageFrame.channel}."
|
||||||
)
|
)
|
||||||
|
|
||||||
RevoltAPI.messageCache[messageFrame.id!!] = messageFrame
|
if (messageFrame.id == null) {
|
||||||
|
Log.d("RealtimeSocket", "Message frame has no ID or channel. Ignoring.")
|
||||||
// Update last message ID for channel - important for unreads
|
return
|
||||||
messageFrame.channel?.let {
|
|
||||||
RevoltAPI.channelCache[it] =
|
|
||||||
RevoltAPI.channelCache[it]!!.copy(lastMessageID = messageFrame.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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" -> {
|
"ChannelStartTyping" -> {
|
||||||
val typingFrame =
|
val typingFrame =
|
||||||
RevoltJson.decodeFromString(ChannelStartTypingFrame.serializer(), rawFrame)
|
RevoltJson.decodeFromString(ChannelStartTypingFrame.serializer(), rawFrame)
|
||||||
|
|
@ -150,8 +234,9 @@ object RealtimeSocket {
|
||||||
"Received channel start typing frame for ${typingFrame.id} from ${typingFrame.user}."
|
"Received channel start typing frame for ${typingFrame.id} from ${typingFrame.user}."
|
||||||
)
|
)
|
||||||
|
|
||||||
channelCallbacks[typingFrame.id]?.onStartTyping(typingFrame)
|
ChannelCallbacks.emitStartTyping(typingFrame.id, typingFrame.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
"ChannelStopTyping" -> {
|
"ChannelStopTyping" -> {
|
||||||
val typingFrame =
|
val typingFrame =
|
||||||
RevoltJson.decodeFromString(ChannelStopTypingFrame.serializer(), rawFrame)
|
RevoltJson.decodeFromString(ChannelStopTypingFrame.serializer(), rawFrame)
|
||||||
|
|
@ -160,8 +245,9 @@ object RealtimeSocket {
|
||||||
"Received channel stop typing frame for ${typingFrame.id} from ${typingFrame.user}."
|
"Received channel stop typing frame for ${typingFrame.id} from ${typingFrame.user}."
|
||||||
)
|
)
|
||||||
|
|
||||||
channelCallbacks[typingFrame.id]?.onStopTyping(typingFrame)
|
ChannelCallbacks.emitStopTyping(typingFrame.id, typingFrame.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
"UserUpdate" -> {
|
"UserUpdate" -> {
|
||||||
val userUpdateFrame =
|
val userUpdateFrame =
|
||||||
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)
|
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)
|
||||||
|
|
@ -172,6 +258,7 @@ object RealtimeSocket {
|
||||||
RevoltAPI.userCache[userUpdateFrame.id] =
|
RevoltAPI.userCache[userUpdateFrame.id] =
|
||||||
existing.mergeWithPartial(userUpdateFrame.data)
|
existing.mergeWithPartial(userUpdateFrame.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
"ChannelUpdate" -> {
|
"ChannelUpdate" -> {
|
||||||
val channelUpdateFrame =
|
val channelUpdateFrame =
|
||||||
RevoltJson.decodeFromString(ChannelUpdateFrame.serializer(), rawFrame)
|
RevoltJson.decodeFromString(ChannelUpdateFrame.serializer(), rawFrame)
|
||||||
|
|
@ -182,6 +269,7 @@ object RealtimeSocket {
|
||||||
RevoltAPI.channelCache[channelUpdateFrame.id] =
|
RevoltAPI.channelCache[channelUpdateFrame.id] =
|
||||||
existing.mergeWithPartial(channelUpdateFrame.data)
|
existing.mergeWithPartial(channelUpdateFrame.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
"ChannelAck" -> {
|
"ChannelAck" -> {
|
||||||
val channelAckFrame =
|
val channelAckFrame =
|
||||||
RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame)
|
RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame)
|
||||||
|
|
@ -192,9 +280,11 @@ object RealtimeSocket {
|
||||||
|
|
||||||
RevoltAPI.unreads.processExternalAck(channelAckFrame.id, channelAckFrame.messageId)
|
RevoltAPI.unreads.processExternalAck(channelAckFrame.id, channelAckFrame.messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Authenticated" -> {
|
"Authenticated" -> {
|
||||||
// No effect
|
// No effect
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.i("RealtimeSocket", "Unknown frame: $rawFrame")
|
Log.i("RealtimeSocket", "Unknown frame: $rawFrame")
|
||||||
}
|
}
|
||||||
|
|
@ -202,12 +292,10 @@ object RealtimeSocket {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun invalidateAllChannelStates() {
|
private fun invalidateAllChannelStates() {
|
||||||
channelCallbacks.forEach { (_, callback) ->
|
ChannelCallbacks.emitReconnect()
|
||||||
callback.onStateInvalidate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChannelCallback {
|
/*interface ChannelCallback {
|
||||||
fun onStartTyping(typing: ChannelStartTypingFrame)
|
fun onStartTyping(typing: ChannelStartTypingFrame)
|
||||||
fun onStopTyping(typing: ChannelStopTypingFrame)
|
fun onStopTyping(typing: ChannelStopTypingFrame)
|
||||||
fun onMessage(message: MessageFrame)
|
fun onMessage(message: MessageFrame)
|
||||||
|
|
@ -226,5 +314,5 @@ object RealtimeSocket {
|
||||||
channelCallbacks.remove(channelId)
|
channelCallbacks.remove(channelId)
|
||||||
|
|
||||||
Log.d("RealtimeSocket", "Unregistered channel callback for $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
|
if (message.content.isBlank()) return@let // if only an attachment is sent
|
||||||
|
|
||||||
AndroidView(factory = { ctx ->
|
AndroidView(factory = { ctx ->
|
||||||
android.widget.TextView(ctx).apply {
|
androidx.appcompat.widget.AppCompatTextView(ctx).apply {
|
||||||
text = parse(message)
|
text = parse(message)
|
||||||
maxLines = if (truncate) 1 else Int.MAX_VALUE
|
maxLines = if (truncate) 1 else Int.MAX_VALUE
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
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(
|
AndroidView(
|
||||||
factory = {
|
factory = {
|
||||||
android.widget.TextView(it).apply {
|
androidx.appcompat.widget.AppCompatTextView(it).apply {
|
||||||
ellipsize = TextUtils.TruncateAt.END
|
ellipsize = TextUtils.TruncateAt.END
|
||||||
typeface = ResourcesCompat.getFont(it, R.font.inter)
|
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.ChannelInfoSheet
|
||||||
import chat.revolt.screens.chat.sheets.MessageContextSheet
|
import chat.revolt.screens.chat.sheets.MessageContextSheet
|
||||||
import chat.revolt.screens.chat.sheets.StatusSheet
|
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.HomeScreen
|
||||||
import chat.revolt.screens.chat.views.NoCurrentChannelScreen
|
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.ExperimentalMaterialNavigationApi
|
||||||
import com.google.accompanist.navigation.material.ModalBottomSheetLayout
|
import com.google.accompanist.navigation.material.ModalBottomSheetLayout
|
||||||
import com.google.accompanist.navigation.material.bottomSheet
|
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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.*
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import chat.revolt.R
|
import chat.revolt.R
|
||||||
|
|
@ -35,21 +32,8 @@ import chat.revolt.RevoltTweenDp
|
||||||
import chat.revolt.RevoltTweenFloat
|
import chat.revolt.RevoltTweenFloat
|
||||||
import chat.revolt.RevoltTweenInt
|
import chat.revolt.RevoltTweenInt
|
||||||
import chat.revolt.api.RevoltAPI
|
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.FileArgs
|
||||||
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
|
import chat.revolt.callbacks.ChannelCallbacks
|
||||||
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.UiCallbacks
|
import chat.revolt.callbacks.UiCallbacks
|
||||||
import chat.revolt.components.chat.Message
|
import chat.revolt.components.chat.Message
|
||||||
import chat.revolt.components.chat.MessageField
|
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.SimpleMarkdownRules
|
||||||
import com.discord.simpleast.core.simple.SimpleRenderer
|
import com.discord.simpleast.core.simple.SimpleRenderer
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.sentry.Sentry
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import java.io.File
|
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
|
@Composable
|
||||||
fun ChannelScreen(
|
fun ChannelScreen(
|
||||||
|
|
@ -481,7 +112,7 @@ fun ChannelScreen(
|
||||||
|
|
||||||
DisposableEffect(channelId) {
|
DisposableEffect(channelId) {
|
||||||
onDispose {
|
onDispose {
|
||||||
RealtimeSocket.unregisterChannelCallback(channelId)
|
viewModel.channelCallbackReceiver?.let { ChannelCallbacks.unregisterReceiver(channelId) }
|
||||||
viewModel.uiCallbackReceiver?.let { UiCallbacks.unregisterReceiver(it) }
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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:windowSplashScreenBackground">@color/background</item>
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@
|
||||||
<string name="channel_notes">Notes</string>
|
<string name="channel_notes">Notes</string>
|
||||||
|
|
||||||
<string name="start_of_conversation">This is the start of your conversation</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_dm">Message @%1$s</string>
|
||||||
<string name="message_field_placeholder_text">Message #%1$s</string>
|
<string name="message_field_placeholder_text">Message #%1$s</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue