feat: channel unreads and channel start indicator

This commit is contained in:
Infi 2023-02-27 23:28:39 +01:00
parent 45bfefbebf
commit b0579d1436
12 changed files with 335 additions and 50 deletions

View File

@ -8,6 +8,7 @@ import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf
import chat.revolt.api.schemas.*
import chat.revolt.api.unreads.Unreads
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.*
@ -75,6 +76,8 @@ object RevoltAPI {
val emojiCache = mutableStateMapOf<String, Emoji>()
val messageCache = mutableStateMapOf<String, Message>()
val unreads = Unreads()
var selfId: String? = null
var sessionToken: String = ""
@ -89,8 +92,8 @@ object RevoltAPI {
suspend fun loginAs(token: String) {
setSessionHeader(token)
fetchSelf()
startSocketOps()
unreads.sync()
}
suspend fun connectWS() {
@ -154,6 +157,8 @@ object RevoltAPI {
emojiCache.clear()
messageCache.clear()
unreads.clear()
socketThread?.interrupt()
}

View File

@ -24,7 +24,7 @@ enum class DisconnectionState {
object RealtimeSocket {
var socket: WebSocketSession? = null
private var _disconnectionState = mutableStateOf(DisconnectionState.Disconnected)
private var _disconnectionState = mutableStateOf(DisconnectionState.Reconnecting)
val disconnectionState: DisconnectionState
get() = _disconnectionState.value
@ -37,7 +37,7 @@ object RealtimeSocket {
Log.d("RealtimeSocket", "Already connected to websocket. Refusing to connect again.")
return
}
socket?.close(CloseReason(CloseReason.Codes.NORMAL, "Reconnecting to websocket."))
RevoltHttp.ws(REVOLT_WEBSOCKET) {
@ -52,7 +52,15 @@ object RealtimeSocket {
val authFrameString =
RevoltJson.encodeToString(AuthorizationFrame.serializer(), authFrame)
Log.d("RealtimeSocket", "Sending authorization frame: $authFrameString")
Log.d(
"RealtimeSocket",
"Sending authorization frame: ${
authFrameString.replace(
token,
"X".repeat(token.length)
)
}"
)
send(RevoltJson.encodeToString(AuthorizationFrame.serializer(), authFrame))
incoming.consumeEach { frame ->
@ -126,6 +134,12 @@ object RealtimeSocket {
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)
}
channelCallbacks[messageFrame.channel]?.onMessage(messageFrame)
}
"ChannelStartTyping" -> {
@ -168,6 +182,16 @@ object RealtimeSocket {
RevoltAPI.channelCache[channelUpdateFrame.id] =
existing.mergeWithPartial(channelUpdateFrame.data)
}
"ChannelAck" -> {
val channelAckFrame =
RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received channel ack frame for ${channelAckFrame.id} with new newest ${channelAckFrame.messageId}."
)
RevoltAPI.unreads.processExternalAck(channelAckFrame.id, channelAckFrame.messageId)
}
"Authenticated" -> {
// No effect
}

View File

@ -89,4 +89,10 @@ suspend fun sendMessage(
.bodyAsText()
return response
}
suspend fun ackChannel(channelId: String, messageId: String = ULID.makeNext()) {
RevoltHttp.put("/channels/$channelId/ack/$messageId") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
}
}

View File

@ -0,0 +1,11 @@
package chat.revolt.api.routes.server
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import io.ktor.client.request.*
suspend fun ackServer(serverId: String) {
RevoltHttp.put("/servers/$serverId/ack") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
}
}

View File

@ -0,0 +1,21 @@
package chat.revolt.api.routes.sync
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.ChannelUnreadResponse
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.serialization.builtins.ListSerializer
suspend fun syncUnreads(): List<ChannelUnreadResponse> {
val response = RevoltHttp.get("/sync/unreads") {
headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken)
}
.bodyAsText()
return RevoltJson.decodeFromString(
ListSerializer(ChannelUnreadResponse.serializer()),
response
)
}

View File

@ -1,9 +1,9 @@
package chat.revolt.api.schemas
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
@Serializable
data class MessagesInChannel(
@ -43,6 +43,7 @@ data class Channel(
val description: String? = null,
val recipients: List<String>? = null,
val icon: AutumnResource? = null,
@SerialName("last_message_id")
val lastMessageID: String? = null,
val active: Boolean? = null,
val permissions: Long? = null,
@ -107,4 +108,26 @@ enum class ChannelType(val value: String) {
return encoder.encodeString(value.value)
}
}
}
}
@Serializable
data class ChannelUserChoice(
val channel: String,
val user: String,
)
@Serializable
data class ChannelUnreadResponse(
@SerialName("_id")
val id: ChannelUserChoice,
val last_id: String? = null,
val mentions: List<String>? = null,
)
@Serializable
data class ChannelUnread(
@SerialName("_id")
val id: String,
val last_id: String? = null,
val mentions: List<String>? = null,
)

View File

@ -0,0 +1,82 @@
package chat.revolt.api.unreads
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID
import chat.revolt.api.routes.channel.ackChannel
import chat.revolt.api.routes.server.ackServer
import chat.revolt.api.routes.sync.syncUnreads
import chat.revolt.api.schemas.ChannelUnread
class Unreads {
private val hasLoaded = mutableStateOf(false)
private val channels = mutableStateMapOf<String, ChannelUnread>()
suspend fun sync() {
channels.clear()
channels.putAll(syncUnreads().associate {
it.id.channel to ChannelUnread(
id = it.id.channel,
last_id = it.last_id,
mentions = it.mentions
)
})
hasLoaded.value = true
}
fun getForChannel(channelId: String): ChannelUnread? {
if (!hasLoaded.value) return null
return channels[channelId]
}
fun hasUnread(channelId: String, lastMessageId: String): Boolean {
if (!hasLoaded.value) return false
return (channels[channelId]?.last_id?.compareTo(lastMessageId) ?: 0) < 0
}
suspend fun markAsRead(channelId: String, messageId: String, sync: Boolean = true) {
if (!hasLoaded.value) return
channels[channelId]?.let {
if (it.last_id == messageId) {
channels.remove(channelId)
} else {
channels[channelId] = it.copy(last_id = messageId)
}
}
if (sync) {
ackChannel(channelId, messageId)
}
}
fun processExternalAck(channelId: String, messageId: String) {
channels[channelId]?.let {
if (it.last_id == messageId) {
channels.remove(channelId)
} else {
channels[channelId] = it.copy(last_id = messageId)
}
}
}
suspend fun markServerAsRead(serverId: String, sync: Boolean = true) {
if (!hasLoaded.value) return
val server = RevoltAPI.serverCache[serverId] ?: return
server.channels?.forEach { channel ->
channels[channel] = channels[channel]?.copy(last_id = ULID.makeNext()) ?: ChannelUnread(
channel,
ULID.makeNext()
)
}
if (sync) {
ackServer(serverId)
}
}
fun clear() {
channels.clear()
hasLoaded.value = false
}
}

View File

@ -1,17 +1,23 @@
package chat.revolt.components.screens.chat.drawer.server
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.screens.chat.ChannelIcon
@ -21,25 +27,58 @@ fun DrawerChannel(
channelType: ChannelType,
name: String,
selected: Boolean,
hasUnread: Boolean,
onClick: () -> Unit
) {
val backgroundColor = animateColorAsState(
if (selected) MaterialTheme.colorScheme.background
else MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
animationSpec = spring()
)
val unreadDotOpacity = animateFloatAsState(
if (hasUnread) 1f else 0f,
animationSpec = spring()
)
val channelAlpha = animateFloatAsState(
if (hasUnread) 1f else 0.8f,
animationSpec = spring()
)
Row(
modifier = Modifier
.padding(vertical = 4.dp, horizontal = 8.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(
if (selected) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
)
.background(backgroundColor.value)
.alpha(channelAlpha.value)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(vertical = 8.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
ChannelIcon(channelType = channelType, modifier = Modifier.padding(end = 8.dp))
Text(
text = name,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
)
if (hasUnread) {
Box(
modifier = Modifier
.offset(x = (-8).dp)
.clip(CircleShape)
.background(LocalContentColor.current)
.alpha(unreadDotOpacity.value)
.size(8.dp)
)
} else {
Spacer(modifier = Modifier.size(8.dp))
}
}
}

View File

@ -201,6 +201,12 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
selected = channel.id == (navBackStackEntry?.arguments?.getString(
"channelId"
) ?: false),
hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread(
channel.id!!,
lastMessageID
)
} ?: false,
onClick = {
navController.navigate("channel/${channel.id}")
scope.launch {
@ -234,6 +240,12 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
selected = navBackStackEntry?.arguments?.getString(
"channelId"
) == ch.id,
hasUnread = ch.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread(
ch.id!!,
lastMessageID
)
} ?: true,
onClick = {
scope.launch { drawerState.focusCenter() }
navController.navigate("channel/${ch.id}") {

View File

@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -29,38 +28,34 @@ fun ChannelInfoSheet(
return
}
Surface(
modifier = Modifier.fillMaxSize(),
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
Row(
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
ChannelIcon(
channelType = channel.channelType ?: ChannelType.TextChannel,
modifier = Modifier.size(32.dp)
)
PageHeader(
text = channel.name ?: channel.id ?: "",
modifier = Modifier.offset((-4).dp, 0.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.channel_info_sheet_description),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 10.dp)
ChannelIcon(
channelType = channel.channelType ?: ChannelType.TextChannel,
modifier = Modifier.size(32.dp)
)
Text(
text = channel.description
?: stringResource(id = R.string.channel_info_sheet_description_empty),
modifier = Modifier.padding(bottom = 10.dp)
PageHeader(
text = channel.name ?: channel.id ?: "",
modifier = Modifier.offset((-4).dp, 0.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.channel_info_sheet_description),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 10.dp)
)
Text(
text = channel.description
?: stringResource(id = R.string.channel_info_sheet_description_empty),
modifier = Modifier.padding(bottom = 10.dp)
)
}
}

View File

@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
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.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
@ -39,6 +40,7 @@ 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.sendMessage
import chat.revolt.api.routes.microservices.autumn.FileArgs
@ -54,6 +56,8 @@ import chat.revolt.components.screens.chat.ChannelIcon
import chat.revolt.components.screens.chat.ReplyManager
import chat.revolt.components.screens.chat.TypingIndicator
import io.ktor.http.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
@ -145,6 +149,14 @@ class ChannelScreenViewModel : ViewModel() {
_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
@ -156,6 +168,7 @@ class ChannelScreenViewModel : ViewModel() {
}
regroupMessages(listOf(message) + renderableMessages)
ackNewest()
}
override fun onStartTyping(typing: ChannelStartTypingFrame) {
@ -206,7 +219,11 @@ class ChannelScreenViewModel : ViewModel() {
viewModelScope.launch {
val messages = arrayListOf<MessageSchema>()
fetchMessagesFromChannel(channel!!.id!!, limit = 50, false).let {
it.messages!!.forEach { message ->
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
@ -233,7 +250,11 @@ class ChannelScreenViewModel : ViewModel() {
true,
before = renderableMessages.last().id
).let {
it.messages!!.forEach { message ->
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
@ -243,7 +264,11 @@ class ChannelScreenViewModel : ViewModel() {
}
} else {
fetchMessagesFromChannel(channel!!.id!!, limit = 50, true).let {
it.messages!!.forEach { message ->
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
@ -265,6 +290,10 @@ class ChannelScreenViewModel : ViewModel() {
}
registerCallbacks()
if (channel?.lastMessageID != null) {
ackNewest()
}
}
fun sendPendingMessage() {
@ -337,6 +366,27 @@ class ChannelScreenViewModel : ViewModel() {
setRenderableMessages(groupedMessages)
}
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
@ -450,6 +500,7 @@ fun ChannelScreen(
.collect {
if (it) {
coroutineScope.launch {
if (viewModel.noMoreMessages) return@launch
viewModel.fetchOlderMessages()
}
}
@ -472,11 +523,25 @@ fun ChannelScreen(
}
item {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
if (viewModel.noMoreMessages) {
Text(
text = stringResource(R.string.start_of_conversation),
modifier = Modifier
.padding(16.dp)
.padding(start = 8.dp, end = 8.dp, top = 64.dp, bottom = 32.dp)
.fillMaxWidth(),
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center
)
} else {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.padding(16.dp)
)
}
}
}
}

View File

@ -97,6 +97,8 @@
<string name="channel_group">Group</string>
<string name="channel_notes">Notes</string>
<string name="start_of_conversation">This is the start of your conversation</string>
<string name="message_field_placeholder_dm">Message @%1$s</string>
<string name="message_field_placeholder_text">Message #%1$s</string>
<string name="message_field_placeholder_voice">Message #%1$s</string>