mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix WebSocket real-time messaging
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
---
|
---
|
||||||
alwaysApply: true
|
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.
|
||||||
@@ -111,7 +111,7 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket send helpers
|
// 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")
|
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||||
WebSocketManager.send(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
@@ -123,7 +123,8 @@ object ApiClient {
|
|||||||
data = json.encodeToJsonElement(
|
data = json.encodeToJsonElement(
|
||||||
WebSocketSendMessageRequest(
|
WebSocketSendMessageRequest(
|
||||||
content = content,
|
content = content,
|
||||||
reply_to_id = replyToId
|
reply_to_id = replyToId,
|
||||||
|
client_message_id = clientMessageId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ data class Message(
|
|||||||
val username: String,
|
val username: String,
|
||||||
val profile_picture: String? = null,
|
val profile_picture: String? = null,
|
||||||
val verified: Boolean? = null,
|
val verified: Boolean? = null,
|
||||||
val reply_to: Message? = null
|
val reply_to: Message? = null,
|
||||||
|
val client_message_id: String? = null,
|
||||||
|
val reactions: List<ReactionData>? = null
|
||||||
) {
|
) {
|
||||||
val utcTimestamp = "${timestamp}Z"
|
val utcTimestamp = "${timestamp}Z"
|
||||||
}
|
}
|
||||||
@@ -140,7 +142,8 @@ data class UpdatesMessage(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class WebSocketSendMessageRequest(
|
data class WebSocketSendMessageRequest(
|
||||||
val content: String,
|
val content: String,
|
||||||
val reply_to_id: Int? = null
|
val reply_to_id: Int? = null,
|
||||||
|
val client_message_id: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WebSocketUpdatesData(
|
||||||
|
val seq: Int,
|
||||||
|
val updates: List<WebSocketMessage>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReactionUpdateData(
|
||||||
|
val message_id: Int,
|
||||||
|
val emoji: String,
|
||||||
|
val action: String,
|
||||||
|
val user_id: Int,
|
||||||
|
val username: String,
|
||||||
|
val reactions: List<ReactionData>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReactionData(
|
||||||
|
val emoji: String,
|
||||||
|
val count: Int,
|
||||||
|
val users: List<ReactionUser>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReactionUser(
|
||||||
|
val id: Int,
|
||||||
|
val username: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TypingUpdateData(
|
||||||
|
val userId: Int,
|
||||||
|
val username: String
|
||||||
|
)
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ abstract class ChatPanel(
|
|||||||
// Retry sending
|
// Retry sending
|
||||||
try {
|
try {
|
||||||
// Extract content from message
|
// 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) {
|
} catch (e: Exception) {
|
||||||
timeoutJob.cancel()
|
timeoutJob.cancel()
|
||||||
pendingMessages.remove(tempId)
|
pendingMessages.remove(tempId)
|
||||||
@@ -276,7 +276,7 @@ abstract class ChatPanel(
|
|||||||
|
|
||||||
// Actually send the message
|
// Actually send the message
|
||||||
try {
|
try {
|
||||||
sendMessage(content, replyToId)
|
sendMessage(content, replyToId, tempId)
|
||||||
// Message sent successfully - will be updated when WebSocket confirms
|
// Message sent successfully - will be updated when WebSocket confirms
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
// Remove the temporary message from display
|
// Remove the temporary message from display
|
||||||
@@ -297,7 +297,7 @@ abstract class ChatPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Abstract methods to implement
|
// 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 loadMessages()
|
||||||
abstract suspend fun loadMoreMessages()
|
abstract suspend fun loadMoreMessages()
|
||||||
abstract suspend fun handleWebSocketMessage(message: WebSocketMessage)
|
abstract suspend fun handleWebSocketMessage(message: WebSocketMessage)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import kotlinx.serialization.json.decodeFromJsonElement
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.api.MessageDeletedData
|
import ru.fromchat.api.MessageDeletedData
|
||||||
|
import ru.fromchat.api.ReactionUpdateData
|
||||||
|
import ru.fromchat.api.TypingUpdateData
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
|
|
||||||
class PublicChatPanel(
|
class PublicChatPanel(
|
||||||
@@ -24,8 +27,14 @@ class PublicChatPanel(
|
|||||||
updateState { it.copy(title = chatName) }
|
updateState { it.copy(title = chatName) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(content: String, replyToId: Int?) {
|
private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
|
||||||
ApiClient.sendMessage(content, replyToId)
|
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() {
|
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
|
val json = ApiClient.json
|
||||||
Logger.d("PublicChatPanel", "Handling WebSocket message: type=${message.type}")
|
when (updateMessage.type) {
|
||||||
when (message.type) {
|
|
||||||
"newMessage" -> {
|
"newMessage" -> {
|
||||||
val data = message.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
// Data is directly a Message, not wrapped
|
|
||||||
val newMsg = json.decodeFromJsonElement<Message>(data)
|
val newMsg = json.decodeFromJsonElement<Message>(data)
|
||||||
Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}")
|
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
|
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
|
||||||
val isOurMessage = newMsg.user_id == currentUserId
|
handleMessageConfirmed(newMsg.client_message_id, newMsg)
|
||||||
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 {
|
} else {
|
||||||
msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(newMsg)
|
addMessage(newMsg)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
"messageEdited" -> {
|
"messageEdited" -> {
|
||||||
val data = message.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
// Data is directly a Message
|
|
||||||
val editedMsg = json.decodeFromJsonElement<Message>(data)
|
val editedMsg = json.decodeFromJsonElement<Message>(data)
|
||||||
updateMessage(editedMsg.id) { editedMsg }
|
updateMessage(editedMsg.id) { editedMsg }
|
||||||
}
|
}
|
||||||
"messageDeleted" -> {
|
"messageDeleted" -> {
|
||||||
val data = message.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
// Data is { message_id: Int }
|
|
||||||
val deletedData = json.decodeFromJsonElement<MessageDeletedData>(data)
|
val deletedData = json.decodeFromJsonElement<MessageDeletedData>(data)
|
||||||
removeMessage(deletedData.message_id)
|
removeMessage(deletedData.message_id)
|
||||||
}
|
}
|
||||||
"typing" -> {
|
"reactionUpdate" -> {
|
||||||
// Typing status is handled in ChatScreen
|
val data = updateMessage.data ?: return
|
||||||
|
val reactionUpdate = json.decodeFromJsonElement<ReactionUpdateData>(data)
|
||||||
|
handleReactionUpdate(reactionUpdate)
|
||||||
}
|
}
|
||||||
|
"typing" -> {
|
||||||
|
val data = updateMessage.data ?: return
|
||||||
|
val typingData = json.decodeFromJsonElement<TypingUpdateData>(data)
|
||||||
|
typingHandler.handleTypingEvent(typingData.userId, typingData.username)
|
||||||
|
}
|
||||||
|
"stopTyping" -> {
|
||||||
|
val data = updateMessage.data ?: return
|
||||||
|
val typingData = json.decodeFromJsonElement<TypingUpdateData>(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<WebSocketUpdatesData>(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ package ru.fromchat.ui.chat
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
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 kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for handling typing indicators
|
* Interface for handling typing indicators
|
||||||
@@ -12,8 +17,16 @@ import ru.fromchat.api.ApiClient
|
|||||||
interface TypingHandler {
|
interface TypingHandler {
|
||||||
fun sendTyping()
|
fun sendTyping()
|
||||||
fun stopTyping()
|
fun stopTyping()
|
||||||
|
fun handleTypingEvent(userId: Int, username: String)
|
||||||
|
fun handleStopTypingEvent(userId: Int)
|
||||||
|
val typingUsers: StateFlow<List<TypingUser>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class TypingUser(
|
||||||
|
val userId: Int,
|
||||||
|
val username: String
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typing handler for public chat using WebSocket
|
* Typing handler for public chat using WebSocket
|
||||||
*/
|
*/
|
||||||
@@ -21,6 +34,8 @@ class PublicChatTypingHandler(
|
|||||||
private val scope: CoroutineScope
|
private val scope: CoroutineScope
|
||||||
) : TypingHandler {
|
) : TypingHandler {
|
||||||
private var stopTypingJob: Job? = null
|
private var stopTypingJob: Job? = null
|
||||||
|
private val _typingUsers = MutableStateFlow<List<TypingUser>>(emptyList())
|
||||||
|
override val typingUsers = _typingUsers.asStateFlow()
|
||||||
|
|
||||||
override fun sendTyping() {
|
override fun sendTyping() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -36,7 +51,7 @@ class PublicChatTypingHandler(
|
|||||||
|
|
||||||
// Schedule stop typing after delay
|
// Schedule stop typing after delay
|
||||||
stopTypingJob = scope.launch {
|
stopTypingJob = scope.launch {
|
||||||
delay(3000) // 3 seconds
|
delay(3.seconds) // 3 seconds
|
||||||
stopTyping()
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user