diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index 19dd844a..dae85751 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -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() val messageCache = mutableStateMapOf() + 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() } diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index 58cc2b64..3ed9f666 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -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 } diff --git a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt index 30a16ef7..30fa0e50 100644 --- a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt +++ b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/server/Server.kt b/app/src/main/java/chat/revolt/api/routes/server/Server.kt new file mode 100644 index 00000000..913dd73e --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/server/Server.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/sync/UnreadSync.kt b/app/src/main/java/chat/revolt/api/routes/sync/UnreadSync.kt new file mode 100644 index 00000000..9a59401c --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/sync/UnreadSync.kt @@ -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 { + val response = RevoltHttp.get("/sync/unreads") { + headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + } + .bodyAsText() + + return RevoltJson.decodeFromString( + ListSerializer(ChannelUnreadResponse.serializer()), + response + ) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/schemas/Channel.kt b/app/src/main/java/chat/revolt/api/schemas/Channel.kt index b592d44b..4408c3e2 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Channel.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Channel.kt @@ -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? = 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) } } -} \ No newline at end of file +} + +@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? = null, +) + +@Serializable +data class ChannelUnread( + @SerialName("_id") + val id: String, + val last_id: String? = null, + val mentions: List? = null, +) \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/unreads/Unreads.kt b/app/src/main/java/chat/revolt/api/unreads/Unreads.kt new file mode 100644 index 00000000..f5317d2e --- /dev/null +++ b/app/src/main/java/chat/revolt/api/unreads/Unreads.kt @@ -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() + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt index 07a83ba2..ccd08e14 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/server/DrawerChannel.kt @@ -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)) + } } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index dfd086d3..8dae598f 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -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}") { diff --git a/app/src/main/java/chat/revolt/screens/chat/sheets/ChannelInfoSheet.kt b/app/src/main/java/chat/revolt/screens/chat/sheets/ChannelInfoSheet.kt index 823dde7b..4b34b54a 100644 --- a/app/src/main/java/chat/revolt/screens/chat/sheets/ChannelInfoSheet.kt +++ b/app/src/main/java/chat/revolt/screens/chat/sheets/ChannelInfoSheet.kt @@ -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) + ) } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt index 67b8423d..8f95db8f 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt @@ -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(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() 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) + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5252e573..aa1a2bca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -97,6 +97,8 @@ Group Notes + This is the start of your conversation + Message @%1$s Message #%1$s Message #%1$s