diff --git a/CHANGELOG.md b/CHANGELOG.md index 5822c94..3113e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -482,6 +482,13 @@ przy wywołaniu serializera, tj. np. `Json.encodeToString()` zserializuje obiekt nadrzędny nam chodzi, więc powinniśmy zapisać `Json.encodeToString()` i tego mi właśnie na początku zabrakło. +Idziemy dalej i implementujemy statusy o pisaniu wiadomości. Na razie zrobiłem to tak, że user +piszący wiadomość wysyła pusty obiekt websocketem do serwera, który rozgłasza info o tym, że ten +user coś pisze w obiekcie z jego participant id wszystkim zainteresowanym. Następnie ci wszyscy +zainteresowani trzymają listę wszystkich osób, które coś pisały i okresowo usuwają z niej te wpisy, +które są starsze niż 1 sekunda. Dzięki temu w UI pokazujemy tych, którzy faktycznie coś w danym +momencie piszą i ukrywamy, kiedy pisać przestają. + ## Materiały - biblioteki KMM 1 - https://github.com/terrakok/kmm-awesome diff --git a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/ws/ConversationClient.kt b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/ws/ConversationClient.kt index 1520ce6..66af53e 100644 --- a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/ws/ConversationClient.kt +++ b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/ws/ConversationClient.kt @@ -5,6 +5,8 @@ import io.github.mklkj.kommunicator.data.models.MessageBroadcast import io.github.mklkj.kommunicator.data.models.MessageEvent import io.github.mklkj.kommunicator.data.models.MessagePush import io.github.mklkj.kommunicator.data.models.MessageRequest +import io.github.mklkj.kommunicator.data.models.TypingBroadcast +import io.github.mklkj.kommunicator.data.models.TypingPush import io.github.mklkj.kommunicator.data.repository.MessagesRepository import io.github.mklkj.kommunicator.getDeserialized import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession @@ -16,9 +18,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlinx.uuid.UUID import org.koin.core.annotation.Factory +import kotlin.time.Duration.Companion.seconds private const val TAG = "ConversationClient" @@ -32,6 +44,8 @@ class ConversationClient( private var chatSession: DefaultClientWebSocketSession? = null private var chatId: UUID? = null + private val typingChannel = Channel() + val typingParticipants = MutableStateFlow>(emptyMap()) fun connect(chatId: UUID, onFailure: (Throwable) -> Unit) { Logger.withTag(TAG).i("Connecting to websocket on $chatId chat") @@ -39,6 +53,8 @@ class ConversationClient( scope.launch { runCatching { chatSession = messagesRepository.getChatSession(chatId) + initializeTypingObserver() + initializeTypingStaleTimer() observeIncomingMessages() }.onFailure(onFailure) } @@ -54,13 +70,24 @@ class ConversationClient( messagesRepository.handleReceivedMessage(chatId ?: return, messageEvent) } + is TypingBroadcast -> { + Logger.withTag(TAG).i("Receive typing from: ${messageEvent.participantId}") + + typingParticipants.update { + it + mapOf(messageEvent.participantId to Clock.System.now()) + } + } + // not implemented on server is MessagePush -> Unit + TypingPush -> Unit } } } - fun sendMessage(chatId: UUID, message: MessageRequest) { + fun sendMessage(message: MessageRequest) { + val chatId = chatId ?: return + scope.launch { when (chatSession) { null -> { @@ -82,6 +109,34 @@ class ConversationClient( } } + private fun initializeTypingObserver() { + scope.launch { + typingChannel.consumeAsFlow() +// .debounce(0.5.seconds) + .onEach { + chatSession?.sendSerialized(TypingPush) + } + .collect() + } + } + + private fun initializeTypingStaleTimer() { + scope.launch { + while (true) { + typingParticipants.update { participants -> + participants.filterValues { lastUpdate -> + Clock.System.now().minus(lastUpdate) <= 1.seconds + } + } + delay(1.seconds) + } + } + } + + fun onTyping() { + typingChannel.trySend(Unit) + } + fun onDispose() { chatSession?.cancel() } diff --git a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationScreen.kt b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationScreen.kt index a8591fb..db0c453 100644 --- a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationScreen.kt @@ -40,10 +40,10 @@ import cafe.adriel.voyager.koin.getScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import io.github.mklkj.kommunicator.data.db.entity.LocalMessage -import io.github.mklkj.kommunicator.data.models.Message import io.github.mklkj.kommunicator.data.models.MessageRequest import io.github.mklkj.kommunicator.ui.utils.collectAsStateWithLifecycle import io.github.mklkj.kommunicator.ui.utils.scaffoldPadding +import kotlinx.datetime.Instant import kotlinx.uuid.UUID class ConversationScreen(private val chatId: UUID) : Screen { @@ -74,6 +74,10 @@ class ConversationScreen(private val chatId: UUID) : Screen { chatListState.animateScrollToItem(0) } + LaunchedEffect(state.typingParticipants) { + chatListState.animateScrollToItem(0) + } + Scaffold( topBar = { TopAppBar( @@ -95,19 +99,51 @@ class ConversationScreen(private val chatId: UUID) : Screen { reverseLayout = true, modifier = Modifier.weight(1f) ) { + items(state.typingParticipants.toList(), key = { it.first.toString() }) { + ChatTyping(it.second) + } items(state.messages, key = { it.id.toString() }) { ChatMessage(it) } } ChatInput( isLoading = state.isLoading, - onSendClick = { viewModel.sendMessage(chatId, it) }, + onTyping = viewModel::onTyping, + onSendClick = viewModel::sendMessage, modifier = Modifier.fillMaxWidth().imePadding() ) } } } + @Composable + private fun ChatTyping(time: Instant, modifier: Modifier = Modifier) { + val bubbleColor = MaterialTheme.colorScheme.surface + + Box( + contentAlignment = Alignment.CenterStart, + modifier = modifier + .padding(end = 50.dp) + .fillMaxWidth() + ) { + Text( + text = "...\n$time", + modifier = Modifier + .padding(8.dp) + .clip( + RoundedCornerShape( + bottomStart = 20.dp, + bottomEnd = 20.dp, + topEnd = 20.dp, + topStart = 2.dp + ) + ) + .background(bubbleColor) + .padding(16.dp) + ) + } + } + @Composable private fun ChatMessage(message: LocalMessage, modifier: Modifier = Modifier) { val bubbleColor = when { @@ -148,6 +184,7 @@ class ConversationScreen(private val chatId: UUID) : Screen { @Composable private fun ChatInput( isLoading: Boolean, + onTyping: () -> Unit, onSendClick: (MessageRequest) -> Unit, modifier: Modifier = Modifier, ) { @@ -156,7 +193,10 @@ class ConversationScreen(private val chatId: UUID) : Screen { TextField( value = content, - onValueChange = { content = it }, + onValueChange = { + onTyping() + content = it + }, maxLines = 3, placeholder = { Text(text = "Type a message...") }, trailingIcon = { diff --git a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationState.kt b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationState.kt index d5970f7..a81f6f7 100644 --- a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationState.kt +++ b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationState.kt @@ -2,10 +2,13 @@ package io.github.mklkj.kommunicator.ui.modules.conversation import io.github.mklkj.kommunicator.Chats import io.github.mklkj.kommunicator.data.db.entity.LocalMessage +import kotlinx.datetime.Instant +import kotlinx.uuid.UUID data class ConversationState( val isLoading: Boolean = true, val errorMessage: String? = null, val chat: Chats? = null, val messages: List = emptyList(), + val typingParticipants: Map = emptyMap(), ) diff --git a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationViewModel.kt b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationViewModel.kt index 6fb4dcc..fbefd98 100644 --- a/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/io/github/mklkj/kommunicator/ui/modules/conversation/ConversationViewModel.kt @@ -5,7 +5,7 @@ import io.github.mklkj.kommunicator.data.repository.MessagesRepository import io.github.mklkj.kommunicator.data.ws.ConversationClient import io.github.mklkj.kommunicator.ui.base.BaseViewModel import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.uuid.UUID import org.koin.core.annotation.Factory @@ -38,8 +38,12 @@ class ConversationViewModel( ) } - fun sendMessage(chatId: UUID, message: MessageRequest) { - conversationClient.sendMessage(chatId, message) + fun sendMessage(message: MessageRequest) { + conversationClient.sendMessage(message) + } + + fun onTyping() { + conversationClient.onTyping() } override fun onDispose() { @@ -72,13 +76,17 @@ class ConversationViewModel( private fun observeMessages(chatId: UUID) { launch("observe_messages", isFlowObserver = true) { - messagesRepository.observeMessages(chatId) - .onEach { messages -> - mutableState.update { - it.copy(messages = messages) - } + combine( + flow = messagesRepository.observeMessages(chatId), + flow2 = conversationClient.typingParticipants, + ) { messages, typingParticipants -> + mutableState.update { + it.copy( + messages = messages, + typingParticipants = typingParticipants, + ) } - .collect() + }.collect() } } diff --git a/server/src/main/kotlin/io/github/mklkj/kommunicator/routes/ChatRoutes.kt b/server/src/main/kotlin/io/github/mklkj/kommunicator/routes/ChatRoutes.kt index 868d9cd..f92fe83 100644 --- a/server/src/main/kotlin/io/github/mklkj/kommunicator/routes/ChatRoutes.kt +++ b/server/src/main/kotlin/io/github/mklkj/kommunicator/routes/ChatRoutes.kt @@ -7,6 +7,8 @@ import io.github.mklkj.kommunicator.data.models.MessageBroadcast import io.github.mklkj.kommunicator.data.models.MessageEntity import io.github.mklkj.kommunicator.data.models.MessageEvent import io.github.mklkj.kommunicator.data.models.MessagePush +import io.github.mklkj.kommunicator.data.models.TypingBroadcast +import io.github.mklkj.kommunicator.data.models.TypingPush import io.github.mklkj.kommunicator.data.service.ChatService import io.github.mklkj.kommunicator.data.service.MessageService import io.github.mklkj.kommunicator.data.service.NotificationService @@ -136,6 +138,9 @@ fun Route.chatWebsockets() { } else null } .onEach { message -> + val connections = chatConnections.getConnections(chatId) + val participantId = participants.single { userId == it.userId }.id + when (message) { is MessagePush -> { val entity = MessageEntity( @@ -147,16 +152,13 @@ fun Route.chatWebsockets() { ) messageService.saveMessage(entity) - val connections = chatConnections.getConnections(chatId) connections .filterNot { it.userId == userId } .forEach { connection -> println("Notify user: ${connection.userId}") val event = MessageBroadcast( id = entity.id, - participantId = participants - .single { userId == it.userId } - .id, + participantId = participantId, content = entity.content, createdAt = entity.timestamp ) @@ -170,8 +172,20 @@ fun Route.chatWebsockets() { ) } + TypingPush -> { + connections + .filterNot { it.userId == userId } + .forEach { connection -> + val event = TypingBroadcast( + participantId = participantId, + ) + connection.session.sendSerialized(event) + } + } + // not handled on server is MessageBroadcast -> Unit + is TypingBroadcast -> TODO() } } .collect() diff --git a/shared/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/models/MessageEvent.kt b/shared/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/models/MessageEvent.kt index a1321ff..0246b19 100644 --- a/shared/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/models/MessageEvent.kt +++ b/shared/src/commonMain/kotlin/io/github/mklkj/kommunicator/data/models/MessageEvent.kt @@ -23,3 +23,11 @@ data class MessagePush( val id: UUID, val content: String, ) : MessageEvent() + +@Serializable +data object TypingPush : MessageEvent() + +@Serializable +data class TypingBroadcast( + val participantId: UUID, +) : MessageEvent()