diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 3dca909..aceeb65 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -1,3 +1,8 @@ --- alwaysApply: true --- + +When working with the mobile app: + +- Don't say anything when implementing the solution to my problem, just call the necessary tools and after that provide a concise result. +- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors. \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 0e49a9a..50b4b89 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -111,7 +111,7 @@ object ApiClient { } // WebSocket send helpers - suspend fun sendMessage(content: String, replyToId: Int? = null) { + suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) { val token = token ?: throw IllegalStateException("Not authenticated") WebSocketManager.send( WebSocketMessage( @@ -123,7 +123,8 @@ object ApiClient { data = json.encodeToJsonElement( WebSocketSendMessageRequest( content = content, - reply_to_id = replyToId + reply_to_id = replyToId, + client_message_id = clientMessageId ) ) ) diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt index b06ef6a..916145c 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -57,7 +57,9 @@ data class Message( val username: String, val profile_picture: String? = null, val verified: Boolean? = null, - val reply_to: Message? = null + val reply_to: Message? = null, + val client_message_id: String? = null, + val reactions: List? = null ) { val utcTimestamp = "${timestamp}Z" } @@ -140,7 +142,8 @@ data class UpdatesMessage( @Serializable data class WebSocketSendMessageRequest( val content: String, - val reply_to_id: Int? = null + val reply_to_id: Int? = null, + val client_message_id: String? = null ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt new file mode 100644 index 0000000..2ce7ebe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketData.kt @@ -0,0 +1,39 @@ +package ru.fromchat.api + +import kotlinx.serialization.Serializable + +@Serializable +data class WebSocketUpdatesData( + val seq: Int, + val updates: List +) + +@Serializable +data class ReactionUpdateData( + val message_id: Int, + val emoji: String, + val action: String, + val user_id: Int, + val username: String, + val reactions: List +) + +@Serializable +data class ReactionData( + val emoji: String, + val count: Int, + val users: List +) + +@Serializable +data class ReactionUser( + val id: Int, + val username: String +) + +@Serializable +data class TypingUpdateData( + val userId: Int, + val username: String +) + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 2cf0f75..c2d7675 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -201,7 +201,7 @@ abstract class ChatPanel( // Retry sending try { // Extract content from message - sendMessage(message.content, message.reply_to?.id) + sendMessage(message.content, message.reply_to?.id, message.client_message_id) } catch (e: Exception) { timeoutJob.cancel() pendingMessages.remove(tempId) @@ -276,7 +276,7 @@ abstract class ChatPanel( // Actually send the message try { - sendMessage(content, replyToId) + sendMessage(content, replyToId, tempId) // Message sent successfully - will be updated when WebSocket confirms } catch (error: Exception) { // Remove the temporary message from display @@ -297,7 +297,7 @@ abstract class ChatPanel( } // Abstract methods to implement - abstract suspend fun sendMessage(content: String, replyToId: Int?) + abstract suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) abstract suspend fun loadMessages() abstract suspend fun loadMoreMessages() abstract suspend fun handleWebSocketMessage(message: WebSocketMessage) diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index 2903542..63e7118 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -5,7 +5,10 @@ import kotlinx.serialization.json.decodeFromJsonElement import ru.fromchat.api.ApiClient import ru.fromchat.api.Message import ru.fromchat.api.MessageDeletedData +import ru.fromchat.api.ReactionUpdateData +import ru.fromchat.api.TypingUpdateData import ru.fromchat.api.WebSocketMessage +import ru.fromchat.api.WebSocketUpdatesData import ru.fromchat.core.Logger class PublicChatPanel( @@ -24,8 +27,14 @@ class PublicChatPanel( updateState { it.copy(title = chatName) } } - override suspend fun sendMessage(content: String, replyToId: Int?) { - ApiClient.sendMessage(content, replyToId) + private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) { + updateMessage(reactionUpdate.message_id) { message -> + message.copy(reactions = reactionUpdate.reactions) + } + } + + override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { + ApiClient.sendMessage(content, replyToId, clientMessageId) } override suspend fun loadMessages() { @@ -75,57 +84,73 @@ class PublicChatPanel( } } - override suspend fun handleWebSocketMessage(message: WebSocketMessage) { + private suspend fun handleSingleUpdate(updateMessage: WebSocketMessage) { val json = ApiClient.json - Logger.d("PublicChatPanel", "Handling WebSocket message: type=${message.type}") - when (message.type) { + when (updateMessage.type) { "newMessage" -> { - val data = message.data ?: return - // Data is directly a Message, not wrapped + val data = updateMessage.data ?: return val newMsg = json.decodeFromJsonElement(data) Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}") - // Check if this is a confirmation of a message we sent - val isOurMessage = newMsg.user_id == currentUserId - if (isOurMessage) { - // This is our message being confirmed, find the temp message and replace it - val tempMessages = _state.messages.filter { it.id < 0 } - for (tempMsg in tempMessages) { - if (tempMsg.content == newMsg.content) { - // Replace temp message with confirmed - updateState { currentState -> - currentState.copy( - messages = currentState.messages.map { msg -> - if (msg.id < 0 && msg.content == newMsg.content) { - newMsg - } else { - msg - } - } - ) - } - return - } - } + if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) { + handleMessageConfirmed(newMsg.client_message_id, newMsg) + } else { + addMessage(newMsg) } - - addMessage(newMsg) } "messageEdited" -> { - val data = message.data ?: return - // Data is directly a Message + val data = updateMessage.data ?: return val editedMsg = json.decodeFromJsonElement(data) updateMessage(editedMsg.id) { editedMsg } } "messageDeleted" -> { - val data = message.data ?: return - // Data is { message_id: Int } + val data = updateMessage.data ?: return val deletedData = json.decodeFromJsonElement(data) removeMessage(deletedData.message_id) } - "typing" -> { - // Typing status is handled in ChatScreen + "reactionUpdate" -> { + val data = updateMessage.data ?: return + val reactionUpdate = json.decodeFromJsonElement(data) + handleReactionUpdate(reactionUpdate) } + "typing" -> { + val data = updateMessage.data ?: return + val typingData = json.decodeFromJsonElement(data) + typingHandler.handleTypingEvent(typingData.userId, typingData.username) + } + "stopTyping" -> { + val data = updateMessage.data ?: return + val typingData = json.decodeFromJsonElement(data) + typingHandler.handleStopTypingEvent(typingData.userId) + } + "statusUpdate" -> { + // Handled in ChatScreen or by global WebSocketManager listeners + } + "suspended" -> { + // Handled by global WebSocketManager listeners or shown as a toast + } + "account_deleted" -> { + // Handled by global WebSocketManager listeners or shown as a toast + } + else -> { + Logger.w("PublicChatPanel", "Unhandled WebSocket update type: ${updateMessage.type}") + } + } + } + + override suspend fun handleWebSocketMessage(message: WebSocketMessage) { + Logger.d("PublicChatPanel", "Handling raw WebSocket message: type=${message.type}") + if (message.type == "updates") { + val json = ApiClient.json + val data = message.data ?: return + val updatesData = json.decodeFromJsonElement(data) + Logger.d("PublicChatPanel", "Received ${updatesData.updates.size} batched updates (seq: ${updatesData.seq})") + updatesData.updates.forEach { update -> + handleSingleUpdate(update) + } + } else { + // Fallback for non-batched messages (legacy or direct signals) + handleSingleUpdate(message) } } diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt index d3aba55..7216da8 100644 --- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt @@ -3,8 +3,13 @@ package ru.fromchat.ui.chat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient +import kotlin.time.Duration.Companion.seconds /** * Interface for handling typing indicators @@ -12,8 +17,16 @@ import ru.fromchat.api.ApiClient interface TypingHandler { fun sendTyping() fun stopTyping() + fun handleTypingEvent(userId: Int, username: String) + fun handleStopTypingEvent(userId: Int) + val typingUsers: StateFlow> } +data class TypingUser( + val userId: Int, + val username: String +) + /** * Typing handler for public chat using WebSocket */ @@ -21,6 +34,8 @@ class PublicChatTypingHandler( private val scope: CoroutineScope ) : TypingHandler { private var stopTypingJob: Job? = null + private val _typingUsers = MutableStateFlow>(emptyList()) + override val typingUsers = _typingUsers.asStateFlow() override fun sendTyping() { scope.launch { @@ -36,7 +51,7 @@ class PublicChatTypingHandler( // Schedule stop typing after delay stopTypingJob = scope.launch { - delay(3000) // 3 seconds + delay(3.seconds) // 3 seconds stopTyping() } } @@ -52,6 +67,21 @@ class PublicChatTypingHandler( } } } + + override fun handleTypingEvent(userId: Int, username: String) { + _typingUsers.update { currentUsers -> + if (currentUsers.none { it.userId == userId }) { + currentUsers + TypingUser(userId, username) + } else { + currentUsers + } + } + } + + override fun handleStopTypingEvent(userId: Int) { + _typingUsers.update { currentUsers -> + currentUsers.filter { it.userId != userId } + } + } } -