diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
new file mode 100644
index 0000000..810b791
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
@@ -0,0 +1,7 @@
+
+
+ %1$s печатает…
+ %1$s и %2$s печатают…
+ %1$s, %2$s и еще %3$d печатают…
+
+
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 7968c74..b6b6306 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -62,4 +62,9 @@
Invalid username or password
Connection error
An unknown error occurred
+
+
+ %1$s is typing…
+ %1$s and %2$s are typing…
+ %1$s, %2$s and %3$d more are typing…
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 3e47b8b..0e49a9a 100644
--- a/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -9,15 +9,15 @@ import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.bearerAuth
-import io.ktor.client.request.delete
import io.ktor.client.request.get
+import io.ktor.client.request.parameter
import io.ktor.client.request.post
-import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config
import ru.fromchat.utils.failOnError
import kotlin.concurrent.Volatile
@@ -77,10 +77,13 @@ object ApiClient {
}
.failOnError()
- suspend fun getMessages() =
+ suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http
.get("${Config.getApiBaseUrl()}/get_messages") {
contentType(ContentType.Application.Json)
+ bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
+ parameter("limit", limit)
+ beforeId?.let { parameter("before_id", it) }
}
.failOnError()
.body()
@@ -95,23 +98,6 @@ object ApiClient {
.failOnError()
.body()
- suspend fun editMessage(messageId: Int, content: String) =
- http
- .put("${Config.getApiBaseUrl()}/edit_message/$messageId") {
- contentType(ContentType.Application.Json)
- bearerAuth(token!!)
- setBody(EditMessageRequest(content))
- }
- .failOnError()
- .body()
-
- suspend fun deleteMessage(messageId: Int) =
- http
- .delete("${Config.getApiBaseUrl()}/delete_message/$messageId") {
- contentType(ContentType.Application.Json)
- bearerAuth(token!!)
- }
- .failOnError()
suspend fun logout(authToken: String) {
// Don't throw on logout errors, just try to logout
@@ -123,4 +109,97 @@ object ApiClient {
// Ignore logout errors
}
}
+
+ // WebSocket send helpers
+ suspend fun sendMessage(content: String, replyToId: Int? = null) {
+ val token = token ?: throw IllegalStateException("Not authenticated")
+ WebSocketManager.send(
+ WebSocketMessage(
+ type = "sendMessage",
+ credentials = WebSocketCredentials(
+ scheme = "Bearer",
+ credentials = token
+ ),
+ data = json.encodeToJsonElement(
+ WebSocketSendMessageRequest(
+ content = content,
+ reply_to_id = replyToId
+ )
+ )
+ )
+ )
+ }
+
+ suspend fun editMessage(messageId: Int, content: String) {
+ val token = token ?: throw IllegalStateException("Not authenticated")
+ WebSocketManager.send(
+ WebSocketMessage(
+ type = "editMessage",
+ credentials = WebSocketCredentials(
+ scheme = "Bearer",
+ credentials = token
+ ),
+ data = json.encodeToJsonElement(
+ WebSocketEditMessageRequest(
+ message_id = messageId,
+ content = content
+ )
+ )
+ )
+ )
+ }
+
+ suspend fun deleteMessage(messageId: Int) {
+ val token = token ?: throw IllegalStateException("Not authenticated")
+ WebSocketManager.send(
+ WebSocketMessage(
+ type = "deleteMessage",
+ credentials = WebSocketCredentials(
+ scheme = "Bearer",
+ credentials = token
+ ),
+ data = json.encodeToJsonElement(
+ WebSocketDeleteMessageRequest(
+ message_id = messageId
+ )
+ )
+ )
+ )
+ }
+
+ suspend fun sendTyping() {
+ val token = token ?: throw IllegalStateException("Not authenticated")
+ try {
+ WebSocketManager.send(
+ WebSocketMessage(
+ type = "typing",
+ credentials = WebSocketCredentials(
+ scheme = "Bearer",
+ credentials = token
+ )
+ )
+ )
+ } catch (e: Exception) {
+ // Silently ignore if WebSocket is not connected yet
+ // Typing indicators are not critical
+ }
+ }
+
+ suspend fun sendStopTyping() {
+ val token = token ?: throw IllegalStateException("Not authenticated")
+ try {
+ WebSocketManager.send(
+ WebSocketMessage(
+ type = "stopTyping",
+ credentials = WebSocketCredentials(
+ scheme = "Bearer",
+ credentials = token
+ )
+ )
+ )
+ } catch (e: Exception) {
+ // Silently ignore if WebSocket is not connected yet
+ // Typing indicators are not critical
+ }
+ }
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt
index 25bb87f..b06ef6a 100644
--- a/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/Models.kt
@@ -98,4 +98,58 @@ data class WebSocketMessage(
val credentials: WebSocketCredentials? = null,
val data: JsonElement? = null,
val error: WebSocketError? = null
+)
+
+// WebSocket message data types
+@Serializable
+data class NewMessageData(
+ val message: Message
+)
+
+@Serializable
+data class MessageEditedData(
+ val message: Message
+)
+
+@Serializable
+data class MessageDeletedData(
+ val message_id: Int
+)
+
+@Serializable
+data class TypingData(
+ val userId: Int,
+ val username: String
+)
+
+// Batched updates message
+@Serializable
+data class UpdateItem(
+ val type: String,
+ val data: JsonElement? = null
+)
+
+@Serializable
+data class UpdatesMessage(
+ val type: String,
+ val seq: Int,
+ val updates: List
+)
+
+// WebSocket request types
+@Serializable
+data class WebSocketSendMessageRequest(
+ val content: String,
+ val reply_to_id: Int? = null
+)
+
+@Serializable
+data class WebSocketEditMessageRequest(
+ val message_id: Int,
+ val content: String
+)
+
+@Serializable
+data class WebSocketDeleteMessageRequest(
+ val message_id: Int
)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt
index 3e37770..334a6f2 100644
--- a/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/api/WebSocketManager.kt
@@ -19,10 +19,14 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
-import ru.fromchat.core.config.Config
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.core.Logger
+import ru.fromchat.core.config.Config
import kotlin.concurrent.Volatile
import kotlin.coroutines.suspendCoroutine
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
object WebSocketManager {
// Config
@@ -47,6 +51,24 @@ object WebSocketManager {
private var connecting: Boolean = false
@Volatile private var session: DefaultClientWebSocketSession? = null
+ /**
+ * Check if WebSocket is connected
+ */
+ fun isConnected(): Boolean = session != null
+
+ /**
+ * Wait for WebSocket connection with timeout
+ */
+ @OptIn(ExperimentalTime::class)
+ suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
+ if (session != null) return true
+ val startTime = Clock.System.now().toEpochMilliseconds()
+ while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
+ delay(100)
+ }
+ return session != null
+ }
+
fun connect() {
Logger.d("WebSocketManager", "Connecting to WebSocket")
if (connecting) return
@@ -71,7 +93,21 @@ object WebSocketManager {
val text = (frame as? Frame.Text)?.readText() ?: continue
Logger.d("WebSocketManager", "Received payload: $text")
try {
- val msg = json.decodeFromString(text)
+ // Check if this is an "updates" message - it has a different structure
+ val jsonTree = json.parseToJsonElement(text)
+ val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
+
+ val msg = if (messageType == "updates") {
+ // Wrap in WebSocketMessage with the entire JSON tree as data
+ WebSocketMessage(
+ type = "updates",
+ data = jsonTree
+ )
+ } else {
+ // Parse as regular WebSocketMessage
+ json.decodeFromString(text)
+ }
+
globalHandlers.forEach { it(msg) }
_messages.emit(msg)
} catch (e: Throwable) {
@@ -95,10 +131,18 @@ object WebSocketManager {
}
suspend fun send(message: WebSocketMessage) {
- val session = session
- if (session != null) {
+ // Wait for connection if not connected yet
+ if (session == null) {
+ if (!waitForConnection(5000)) {
+ Logger.w("WebSocketManager", "Cannot send message: no active session after waiting")
+ throw IllegalStateException("No active WebSocket session")
+ }
+ }
+
+ val currentSession = session
+ if (currentSession != null) {
try {
- session.send(Frame.Text(json.encodeToString(message)))
+ currentSession.send(Frame.Text(json.encodeToString(message)))
} catch (e: Exception) {
Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e)
throw e
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index 65ac659..6bdd7a2 100644
--- a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -26,6 +26,7 @@ import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
+import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen
@@ -136,7 +137,9 @@ fun App() {
)
}
- composable("chats/publicChat") { /* PublicChatScreen() */ }
+ composable("chats/publicChat") {
+ PublicChatScreen()
+ }
composable("about") {
AboutScreen()
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
new file mode 100644
index 0000000..ae37a19
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt
@@ -0,0 +1,225 @@
+package ru.fromchat.ui.chat
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Send
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import ru.fromchat.api.Message
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Res
+import ru.fromchat.message_placeholder
+
+@Composable
+fun ChatInput(
+ text: String,
+ onTextChange: (String) -> Unit,
+ onSend: (String) -> Unit,
+ typingHandler: TypingHandler,
+ replyTo: Message? = null,
+ editingMessage: Message? = null,
+ onClearReply: () -> Unit,
+ onClearEdit: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val scope = rememberCoroutineScope()
+ var typingJob by remember { mutableStateOf(null) }
+
+ // Handle typing indicator
+ LaunchedEffect(text) {
+ if (text.isNotBlank()) {
+ typingJob?.cancel()
+ typingHandler.sendTyping()
+ typingJob = scope.launch {
+ delay(3000) // 3 seconds
+ typingHandler.stopTyping()
+ }
+ } else {
+ typingJob?.cancel()
+ typingHandler.stopTyping()
+ }
+ }
+
+ Column(modifier = modifier) {
+ // Reply preview
+ replyTo?.let { reply ->
+ ReplyPreviewBar(
+ replyTo = reply,
+ onClose = onClearReply
+ )
+ }
+
+ // Edit preview
+ editingMessage?.let { edit ->
+ EditPreviewBar(
+ message = edit,
+ onClose = onClearEdit
+ )
+ }
+
+ // Input field
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ OutlinedTextField(
+ value = text,
+ onValueChange = onTextChange,
+ modifier = Modifier.weight(1f),
+ placeholder = {
+ Text(
+ text = stringResource(Res.string.message_placeholder),
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ },
+ shape = RoundedCornerShape(24.dp),
+ maxLines = 5,
+ singleLine = false
+ )
+
+ IconButton(
+ onClick = {
+ if (text.isNotBlank()) {
+ onSend(text.trim())
+ onTextChange("")
+ typingHandler.stopTyping()
+ }
+ },
+ enabled = text.isNotBlank()
+ ) {
+ Icon(
+ imageVector = Icons.Default.Send,
+ contentDescription = "Send",
+ tint = if (text.isNotBlank()) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReplyPreviewBar(
+ replyTo: Message,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHighest
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Replying to ${replyTo.username}",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1
+ )
+ }
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun EditPreviewBar(
+ message: Message,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHighest
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Editing message",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = message.content.take(50) + if (message.content.length > 50) "..." else "",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1
+ )
+ }
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
new file mode 100644
index 0000000..2cf0f75
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
@@ -0,0 +1,311 @@
+package ru.fromchat.ui.chat
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import ru.fromchat.api.Message
+import ru.fromchat.api.WebSocketMessage
+import ru.fromchat.core.Logger
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+
+/**
+ * State data class for ChatPanel
+ */
+@Serializable
+data class ChatPanelState(
+ val id: String,
+ val title: String,
+ val messages: List = emptyList(),
+ val isLoading: Boolean = false,
+ val hasMoreMessages: Boolean = false,
+ val isLoadingMore: Boolean = false
+)
+
+/**
+ * Abstract base class for chat panels
+ */
+abstract class ChatPanel(
+ protected val id: String,
+ protected val currentUserId: Int?,
+ protected val scope: CoroutineScope
+) {
+ protected var _state: ChatPanelState = ChatPanelState(
+ id = id,
+ title = ""
+ )
+
+ private val pendingMessages = mutableMapOf>()
+ private var onStateChange: ((ChatPanelState) -> Unit)? = null
+
+ /**
+ * Set state change callback
+ */
+ fun setOnStateChange(callback: (ChatPanelState) -> Unit) {
+ onStateChange = callback
+ }
+
+ /**
+ * Get current state
+ */
+ fun getState(): ChatPanelState = _state.copy()
+
+ /**
+ * Update state
+ */
+ protected fun updateState(updates: (ChatPanelState) -> ChatPanelState) {
+ _state = updates(_state)
+ // Notify state change - ensure callback runs on main thread for Compose
+ val callback = onStateChange
+ val newState = _state.copy()
+ Logger.d("ChatPanel", "State updated: messages=${newState.messages.size}, callback=${callback != null}")
+ if (callback != null) {
+ scope.launch(Dispatchers.Main) {
+ Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages")
+ callback(newState)
+ }
+ }
+ }
+
+ /**
+ * Add message to list
+ */
+ protected fun addMessage(message: Message) {
+ val messageExists = _state.messages.any { it.id == message.id }
+ if (!messageExists) {
+ Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
+ // Add message and sort by timestamp (ISO 8601 strings sort correctly lexicographically)
+ updateState { currentState ->
+ val newMessages = (currentState.messages + message).sortedBy { it.timestamp }
+ Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}")
+ currentState.copy(messages = newMessages)
+ }
+ } else {
+ Logger.d("ChatPanel", "Message already exists: id=${message.id}")
+ }
+ }
+
+ /**
+ * Update existing message
+ */
+ protected fun updateMessage(messageId: Int, updates: (Message) -> Message) {
+ updateState { currentState ->
+ currentState.copy(
+ messages = currentState.messages.map { msg ->
+ if (msg.id == messageId) {
+ updates(msg)
+ } else {
+ msg
+ }
+ }
+ )
+ }
+ }
+
+ /**
+ * Remove message from list
+ */
+ protected fun removeMessage(messageId: Int) {
+ updateState { it.copy(messages = it.messages.filter { msg -> msg.id != messageId }) }
+ }
+
+ /**
+ * Clear all messages
+ */
+ protected fun clearMessages() {
+ updateState { it.copy(messages = emptyList()) }
+ }
+
+ /**
+ * Set loading state
+ */
+ protected fun setLoading(loading: Boolean) {
+ updateState { it.copy(isLoading = loading) }
+ }
+
+ /**
+ * Set has more messages flag
+ */
+ protected fun setHasMoreMessages(hasMore: Boolean) {
+ updateState { it.copy(hasMoreMessages = hasMore) }
+ }
+
+ /**
+ * Set loading more state
+ */
+ protected fun setLoadingMore(loading: Boolean) {
+ updateState { it.copy(isLoadingMore = loading) }
+ }
+
+ /**
+ * Handle message confirmation (replace temp message with confirmed)
+ */
+ fun handleMessageConfirmed(tempId: String, confirmedMessage: Message) {
+ val pending = pendingMessages.remove(tempId)
+ pending?.first?.cancel()
+
+ // Replace temporary message with confirmed one
+ updateState { currentState ->
+ currentState.copy(
+ messages = currentState.messages.map { msg ->
+ // Check if this is the temp message (negative ID)
+ if (msg.id < 0) {
+ // Try to match by content or other criteria
+ // For now, we'll replace based on tempId stored in pendingMessages
+ confirmedMessage
+ } else {
+ msg
+ }
+ }
+ )
+ }
+ }
+
+ /**
+ * Retry failed message
+ */
+ @OptIn(ExperimentalTime::class)
+ suspend fun retryMessage(messageId: Int) {
+ val message = _state.messages.find { it.id == messageId } ?: return
+
+ // Create new temp ID for retry
+ val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
+
+ // Create temp message for retry
+ val tempMessage = message.copy(id = -1)
+
+ // Update message to sending state
+ updateState { currentState ->
+ currentState.copy(
+ messages = currentState.messages.map { msg ->
+ if (msg.id == messageId) {
+ tempMessage
+ } else {
+ msg
+ }
+ }
+ )
+ }
+
+ // Set up timeout
+ val timeoutJob = scope.launch {
+ delay(10000) // 10 seconds
+ handleMessageTimeout(tempId)
+ }
+
+ pendingMessages[tempId] = timeoutJob to tempMessage
+
+ // Retry sending
+ try {
+ // Extract content from message
+ sendMessage(message.content, message.reply_to?.id)
+ } catch (e: Exception) {
+ timeoutJob.cancel()
+ pendingMessages.remove(tempId)
+ // Mark as failed
+ updateMessage(-1) { it.copy() }
+ }
+ }
+
+ /**
+ * Handle message timeout
+ */
+ private fun handleMessageTimeout(tempId: String) {
+ val pending = pendingMessages.remove(tempId)
+ pending?.first?.cancel()
+
+ // Mark message as failed
+ updateState { currentState ->
+ currentState.copy(
+ messages = currentState.messages.map { msg ->
+ if (msg.id < 0) {
+ // Mark as failed - we'll need to add a status field to Message
+ // For now, just keep it
+ msg
+ } else {
+ msg
+ }
+ }
+ )
+ }
+ }
+
+ /**
+ * Delete message immediately from UI
+ */
+ protected fun deleteMessageImmediately(messageId: Int) {
+ removeMessage(messageId)
+ }
+
+ /**
+ * Send message with immediate display (optimistic update)
+ */
+ @OptIn(ExperimentalTime::class)
+ suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) {
+ if (content.isBlank()) return
+
+ // Create temporary message for immediate display
+ val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
+ val tempMessage = Message(
+ id = -1, // Temporary negative ID
+ user_id = currentUserId ?: -1,
+ content = content.trim(),
+ timestamp = Clock.System.now().toString(),
+ is_read = false,
+ is_edited = false,
+ username = "You",
+ reply_to = replyToId?.let { replyId ->
+ _state.messages.find { it.id == replyId }
+ }
+ )
+
+ // Add message immediately
+ addMessage(tempMessage)
+
+ // Set up timeout for failure
+ val timeoutJob = scope.launch {
+ delay(10000) // 10 seconds timeout
+ handleMessageTimeout(tempId)
+ }
+
+ // Store pending message
+ pendingMessages[tempId] = timeoutJob to tempMessage
+
+ // Actually send the message
+ try {
+ sendMessage(content, replyToId)
+ // Message sent successfully - will be updated when WebSocket confirms
+ } catch (error: Exception) {
+ // Remove the temporary message from display
+ removeMessage(-1)
+ pendingMessages.remove(tempId)
+ timeoutJob.cancel()
+ }
+ }
+
+ /**
+ * Clean up pending messages
+ */
+ fun destroy() {
+ pendingMessages.values.forEach { (job, _) ->
+ job.cancel()
+ }
+ pendingMessages.clear()
+ }
+
+ // Abstract methods to implement
+ abstract suspend fun sendMessage(content: String, replyToId: Int?)
+ abstract suspend fun loadMessages()
+ abstract suspend fun loadMoreMessages()
+ abstract suspend fun handleWebSocketMessage(message: WebSocketMessage)
+ abstract suspend fun handleEditMessage(messageId: Int, content: String)
+ abstract suspend fun handleDeleteMessage(messageId: Int)
+
+ // Abstract UI control methods
+ abstract fun showCallButton(): Boolean
+ abstract fun getTypingHandler(): TypingHandler
+}
+
diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
new file mode 100644
index 0000000..55c3b15
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -0,0 +1,324 @@
+package ru.fromchat.ui.chat
+
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.decodeFromJsonElement
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.Message
+import ru.fromchat.api.TypingData
+import ru.fromchat.api.WebSocketManager
+import ru.fromchat.api.WebSocketMessage
+import ru.fromchat.core.Logger
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatScreen(
+ panel: ChatPanel,
+ currentUserId: Int?,
+ modifier: Modifier = Modifier
+) {
+ var panelState by remember(panel) { mutableStateOf(panel.getState()) }
+
+ // Observe state changes
+ LaunchedEffect(panel) {
+ panel.setOnStateChange { newState ->
+ Logger.d("ChatScreen", "State change callback received: messages=${newState.messages.size}")
+ // Force state update to trigger recomposition
+ panelState = newState.copy() // Ensure new instance
+ Logger.d("ChatScreen", "panelState updated: messages=${panelState.messages.size}")
+ }
+ // Initial state
+ panelState = panel.getState()
+ }
+
+ // Debug: Log state changes
+ LaunchedEffect(panelState.messages.size) {
+ Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
+ }
+
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ val listState = rememberLazyListState()
+ val scope = rememberCoroutineScope()
+
+ // UI state
+ var inputText by rememberSaveable { mutableStateOf("") }
+ var replyTo by rememberSaveable { mutableStateOf(null) }
+ var editingMessage by rememberSaveable { mutableStateOf(null) }
+ var contextMenuState by remember {
+ mutableStateOf(
+ ContextMenuState(
+ isOpen = false,
+ message = null,
+ position = IntOffset(0, 0)
+ )
+ )
+ }
+ var typingUsers by remember { mutableStateOf