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.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()
} }

View File

@ -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
} }

View File

@ -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)
}
}

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 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,
)

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 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))
}
} }
} }

View File

@ -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}") {

View File

@ -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)
)
} }
} }

View File

@ -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)
)
}
} }
} }
} }

View File

@ -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>