feat: channel unreads and channel start indicator
This commit is contained in:
parent
45bfefbebf
commit
b0579d1436
|
|
@ -8,6 +8,7 @@ import chat.revolt.api.realtime.DisconnectionState
|
||||||
import chat.revolt.api.realtime.RealtimeSocket
|
import chat.revolt.api.realtime.RealtimeSocket
|
||||||
import chat.revolt.api.routes.user.fetchSelf
|
import chat.revolt.api.routes.user.fetchSelf
|
||||||
import chat.revolt.api.schemas.*
|
import chat.revolt.api.schemas.*
|
||||||
|
import chat.revolt.api.unreads.Unreads
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.engine.okhttp.*
|
import io.ktor.client.engine.okhttp.*
|
||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.plugins.*
|
||||||
|
|
@ -75,6 +76,8 @@ object RevoltAPI {
|
||||||
val emojiCache = mutableStateMapOf<String, Emoji>()
|
val emojiCache = mutableStateMapOf<String, Emoji>()
|
||||||
val messageCache = mutableStateMapOf<String, Message>()
|
val messageCache = mutableStateMapOf<String, Message>()
|
||||||
|
|
||||||
|
val unreads = Unreads()
|
||||||
|
|
||||||
var selfId: String? = null
|
var selfId: String? = null
|
||||||
|
|
||||||
var sessionToken: String = ""
|
var sessionToken: String = ""
|
||||||
|
|
@ -89,8 +92,8 @@ object RevoltAPI {
|
||||||
suspend fun loginAs(token: String) {
|
suspend fun loginAs(token: String) {
|
||||||
setSessionHeader(token)
|
setSessionHeader(token)
|
||||||
fetchSelf()
|
fetchSelf()
|
||||||
|
|
||||||
startSocketOps()
|
startSocketOps()
|
||||||
|
unreads.sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectWS() {
|
suspend fun connectWS() {
|
||||||
|
|
@ -154,6 +157,8 @@ object RevoltAPI {
|
||||||
emojiCache.clear()
|
emojiCache.clear()
|
||||||
messageCache.clear()
|
messageCache.clear()
|
||||||
|
|
||||||
|
unreads.clear()
|
||||||
|
|
||||||
socketThread?.interrupt()
|
socketThread?.interrupt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ enum class DisconnectionState {
|
||||||
object RealtimeSocket {
|
object RealtimeSocket {
|
||||||
var socket: WebSocketSession? = null
|
var socket: WebSocketSession? = null
|
||||||
|
|
||||||
private var _disconnectionState = mutableStateOf(DisconnectionState.Disconnected)
|
private var _disconnectionState = mutableStateOf(DisconnectionState.Reconnecting)
|
||||||
val disconnectionState: DisconnectionState
|
val disconnectionState: DisconnectionState
|
||||||
get() = _disconnectionState.value
|
get() = _disconnectionState.value
|
||||||
|
|
||||||
|
|
@ -52,7 +52,15 @@ object RealtimeSocket {
|
||||||
val authFrameString =
|
val authFrameString =
|
||||||
RevoltJson.encodeToString(AuthorizationFrame.serializer(), authFrame)
|
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))
|
send(RevoltJson.encodeToString(AuthorizationFrame.serializer(), authFrame))
|
||||||
|
|
||||||
incoming.consumeEach { frame ->
|
incoming.consumeEach { frame ->
|
||||||
|
|
@ -126,6 +134,12 @@ object RealtimeSocket {
|
||||||
|
|
||||||
RevoltAPI.messageCache[messageFrame.id!!] = messageFrame
|
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)
|
channelCallbacks[messageFrame.channel]?.onMessage(messageFrame)
|
||||||
}
|
}
|
||||||
"ChannelStartTyping" -> {
|
"ChannelStartTyping" -> {
|
||||||
|
|
@ -168,6 +182,16 @@ object RealtimeSocket {
|
||||||
RevoltAPI.channelCache[channelUpdateFrame.id] =
|
RevoltAPI.channelCache[channelUpdateFrame.id] =
|
||||||
existing.mergeWithPartial(channelUpdateFrame.data)
|
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" -> {
|
"Authenticated" -> {
|
||||||
// No effect
|
// No effect
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,3 +90,9 @@ suspend fun sendMessage(
|
||||||
|
|
||||||
return response
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package chat.revolt.api.schemas
|
package chat.revolt.api.schemas
|
||||||
|
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.*
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
import kotlinx.serialization.descriptors.*
|
import kotlinx.serialization.descriptors.*
|
||||||
import kotlinx.serialization.encoding.*
|
import kotlinx.serialization.encoding.*
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MessagesInChannel(
|
data class MessagesInChannel(
|
||||||
|
|
@ -43,6 +43,7 @@ data class Channel(
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val recipients: List<String>? = null,
|
val recipients: List<String>? = null,
|
||||||
val icon: AutumnResource? = null,
|
val icon: AutumnResource? = null,
|
||||||
|
@SerialName("last_message_id")
|
||||||
val lastMessageID: String? = null,
|
val lastMessageID: String? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val permissions: Long? = null,
|
val permissions: Long? = null,
|
||||||
|
|
@ -108,3 +109,25 @@ enum class ChannelType(val value: String) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
package chat.revolt.components.screens.chat.drawer.server
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.surfaceColorAtElevation
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.revolt.api.schemas.ChannelType
|
import chat.revolt.api.schemas.ChannelType
|
||||||
import chat.revolt.components.screens.chat.ChannelIcon
|
import chat.revolt.components.screens.chat.ChannelIcon
|
||||||
|
|
@ -21,25 +27,58 @@ fun DrawerChannel(
|
||||||
channelType: ChannelType,
|
channelType: ChannelType,
|
||||||
name: String,
|
name: String,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
|
hasUnread: Boolean,
|
||||||
onClick: () -> Unit
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 4.dp, horizontal = 8.dp)
|
.padding(vertical = 4.dp, horizontal = 8.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.background(
|
.background(backgroundColor.value)
|
||||||
if (selected) MaterialTheme.colorScheme.surface
|
.alpha(channelAlpha.value)
|
||||||
else MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
|
|
||||||
)
|
|
||||||
.clickable(onClick = onClick)
|
.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))
|
ChannelIcon(channelType = channelType, modifier = Modifier.padding(end = 8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = name,
|
text = name,
|
||||||
fontWeight = FontWeight.Medium,
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +201,12 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
|
||||||
selected = channel.id == (navBackStackEntry?.arguments?.getString(
|
selected = channel.id == (navBackStackEntry?.arguments?.getString(
|
||||||
"channelId"
|
"channelId"
|
||||||
) ?: false),
|
) ?: false),
|
||||||
|
hasUnread = channel.lastMessageID?.let { lastMessageID ->
|
||||||
|
RevoltAPI.unreads.hasUnread(
|
||||||
|
channel.id!!,
|
||||||
|
lastMessageID
|
||||||
|
)
|
||||||
|
} ?: false,
|
||||||
onClick = {
|
onClick = {
|
||||||
navController.navigate("channel/${channel.id}")
|
navController.navigate("channel/${channel.id}")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -234,6 +240,12 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
|
||||||
selected = navBackStackEntry?.arguments?.getString(
|
selected = navBackStackEntry?.arguments?.getString(
|
||||||
"channelId"
|
"channelId"
|
||||||
) == ch.id,
|
) == ch.id,
|
||||||
|
hasUnread = ch.lastMessageID?.let { lastMessageID ->
|
||||||
|
RevoltAPI.unreads.hasUnread(
|
||||||
|
ch.id!!,
|
||||||
|
lastMessageID
|
||||||
|
)
|
||||||
|
} ?: true,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch { drawerState.focusCenter() }
|
scope.launch { drawerState.focusCenter() }
|
||||||
navController.navigate("channel/${ch.id}") {
|
navController.navigate("channel/${ch.id}") {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -29,38 +28,34 @@ fun ChannelInfoSheet(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
ChannelIcon(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
channelType = channel.channelType ?: ChannelType.TextChannel,
|
||||||
) {
|
modifier = Modifier.size(32.dp)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
Text(
|
PageHeader(
|
||||||
text = channel.description
|
text = channel.name ?: channel.id ?: "",
|
||||||
?: stringResource(id = R.string.channel_info_sheet_description_empty),
|
modifier = Modifier.offset((-4).dp, 0.dp)
|
||||||
modifier = Modifier.padding(bottom = 10.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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.ViewModel
|
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.ChannelStopTypingFrame
|
||||||
import chat.revolt.api.realtime.frames.receivable.MessageFrame
|
import chat.revolt.api.realtime.frames.receivable.MessageFrame
|
||||||
import chat.revolt.api.routes.channel.SendMessageReply
|
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.fetchMessagesFromChannel
|
||||||
import chat.revolt.api.routes.channel.sendMessage
|
import chat.revolt.api.routes.channel.sendMessage
|
||||||
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
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.ReplyManager
|
||||||
import chat.revolt.components.screens.chat.TypingIndicator
|
import chat.revolt.components.screens.chat.TypingIndicator
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
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 kotlinx.datetime.Instant
|
||||||
|
|
@ -145,6 +149,14 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
_replies.clear()
|
_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)
|
private var _uiCallbackReceiver = mutableStateOf<UiCallbacks.CallbackReceiver?>(null)
|
||||||
val uiCallbackReceiver: UiCallbacks.CallbackReceiver?
|
val uiCallbackReceiver: UiCallbacks.CallbackReceiver?
|
||||||
get() = _uiCallbackReceiver.value
|
get() = _uiCallbackReceiver.value
|
||||||
|
|
@ -156,6 +168,7 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
regroupMessages(listOf(message) + renderableMessages)
|
regroupMessages(listOf(message) + renderableMessages)
|
||||||
|
ackNewest()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartTyping(typing: ChannelStartTypingFrame) {
|
override fun onStartTyping(typing: ChannelStartTypingFrame) {
|
||||||
|
|
@ -206,7 +219,11 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val messages = arrayListOf<MessageSchema>()
|
val messages = arrayListOf<MessageSchema>()
|
||||||
fetchMessagesFromChannel(channel!!.id!!, limit = 50, false).let {
|
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)
|
addUserIfUnknown(message.author ?: return@forEach)
|
||||||
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
||||||
RevoltAPI.messageCache[message.id!!] = message
|
RevoltAPI.messageCache[message.id!!] = message
|
||||||
|
|
@ -233,7 +250,11 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
true,
|
true,
|
||||||
before = renderableMessages.last().id
|
before = renderableMessages.last().id
|
||||||
).let {
|
).let {
|
||||||
it.messages!!.forEach { message ->
|
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
|
||||||
|
setNoMoreMessages(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
it.messages?.forEach { message ->
|
||||||
addUserIfUnknown(message.author ?: return@forEach)
|
addUserIfUnknown(message.author ?: return@forEach)
|
||||||
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
||||||
RevoltAPI.messageCache[message.id!!] = message
|
RevoltAPI.messageCache[message.id!!] = message
|
||||||
|
|
@ -243,7 +264,11 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fetchMessagesFromChannel(channel!!.id!!, limit = 50, true).let {
|
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)
|
addUserIfUnknown(message.author ?: return@forEach)
|
||||||
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
||||||
RevoltAPI.messageCache[message.id!!] = message
|
RevoltAPI.messageCache[message.id!!] = message
|
||||||
|
|
@ -265,6 +290,10 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
registerCallbacks()
|
registerCallbacks()
|
||||||
|
|
||||||
|
if (channel?.lastMessageID != null) {
|
||||||
|
ackNewest()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendPendingMessage() {
|
fun sendPendingMessage() {
|
||||||
|
|
@ -337,6 +366,27 @@ class ChannelScreenViewModel : ViewModel() {
|
||||||
|
|
||||||
setRenderableMessages(groupedMessages)
|
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
|
@Composable
|
||||||
|
|
@ -450,6 +500,7 @@ fun ChannelScreen(
|
||||||
.collect {
|
.collect {
|
||||||
if (it) {
|
if (it) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
if (viewModel.noMoreMessages) return@launch
|
||||||
viewModel.fetchOlderMessages()
|
viewModel.fetchOlderMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -472,11 +523,25 @@ fun ChannelScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
if (viewModel.noMoreMessages) {
|
||||||
CircularProgressIndicator(
|
Text(
|
||||||
|
text = stringResource(R.string.start_of_conversation),
|
||||||
modifier = Modifier
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@
|
||||||
<string name="channel_group">Group</string>
|
<string name="channel_group">Group</string>
|
||||||
<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="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>
|
||||||
<string name="message_field_placeholder_voice">Message #%1$s</string>
|
<string name="message_field_placeholder_voice">Message #%1$s</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue