From 3d7bb91b2ceba5ffb672578b89ba0348c861fa7e Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 8 Dec 2025 16:45:32 +0300 Subject: [PATCH] Working public chat --- .../composeResources/values-ru/strings.xml | 7 + .../composeResources/values/strings.xml | 5 + .../kotlin/ru/fromchat/api/ApiClient.kt | 119 +++++-- .../kotlin/ru/fromchat/api/Models.kt | 54 +++ .../ru/fromchat/api/WebSocketManager.kt | 54 ++- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 5 +- .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 225 ++++++++++++ .../kotlin/ru/fromchat/ui/chat/ChatPanel.kt | 311 +++++++++++++++++ .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 324 ++++++++++++++++++ .../ru/fromchat/ui/chat/MessageContextMenu.kt | 163 +++++++++ .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 240 +++++++++++++ .../ru/fromchat/ui/chat/PublicChatPanel.kt | 148 ++++++++ .../ru/fromchat/ui/chat/PublicChatScreen.kt | 37 ++ .../ru/fromchat/ui/chat/TypingHandler.kt | 56 +++ .../ru/fromchat/ui/chat/TypingIndicator.kt | 129 +++++++ 15 files changed, 1851 insertions(+), 26 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/values-ru/strings.xml create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt create mode 100644 composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt 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>(emptyMap()) } + + // Collect WebSocket messages + LaunchedEffect(Unit) { + WebSocketManager.messages.collect { message -> + Logger.d("ChatScreen", "Received WebSocket message: type=${message.type}, data=${message.data != null}") + when (message.type) { + "updates" -> { + // Handle batched updates + Logger.d("ChatScreen", "Processing updates message") + val data = message.data + if (data == null) { + Logger.w("ChatScreen", "Updates message has no data, skipping") + return@collect + } + Logger.d("ChatScreen", "Updates message has data, parsing...") + val json = ApiClient.json + try { + Logger.d("ChatScreen", "Parsing updates message") + val updatesMessage = json.decodeFromJsonElement(data) + Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates") + // Process each update in the batch + updatesMessage.updates.forEach { update -> + Logger.d("ChatScreen", "Processing update: type=${update.type}, data=${update.data != null}") + val wsMessage = WebSocketMessage( + type = update.type, + data = update.data + ) + when (update.type) { + "newMessage", "messageEdited", "messageDeleted" -> { + Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}") + scope.launch { + try { + panel.handleWebSocketMessage(wsMessage) + } catch (e: Exception) { + Logger.e("ChatScreen", "Error handling WebSocket message: ${e.message}", e) + } + } + } + "typing" -> { + val updateData = update.data ?: return@forEach + val typingData = json.decodeFromJsonElement(updateData) + if (typingData.userId != currentUserId) { + typingUsers = typingUsers + (typingData.userId to typingData.username) + // Remove after timeout + scope.launch { + kotlinx.coroutines.delay(3000) + typingUsers = typingUsers - typingData.userId + } + } + } + "stopTyping" -> { + val updateData = update.data ?: return@forEach + val typingData = json.decodeFromJsonElement(updateData) + typingUsers = typingUsers - typingData.userId + } + } + } + } catch (e: Exception) { + Logger.e("ChatScreen", "Error parsing updates message: ${e.message}", e) + e.printStackTrace() + } + } + "newMessage", "messageEdited", "messageDeleted" -> { + scope.launch { + panel.handleWebSocketMessage(message) + } + } + "typing" -> { + val data = message.data ?: return@collect + val json = ApiClient.json + val typingData = json.decodeFromJsonElement(data) + if (typingData.userId != currentUserId) { + typingUsers = typingUsers + (typingData.userId to typingData.username) + // Remove after timeout + scope.launch { + kotlinx.coroutines.delay(3000) + typingUsers = typingUsers - typingData.userId + } + } + } + } + } + } + + // Scroll to bottom when new messages arrive + LaunchedEffect(panelState.messages.size) { + if (panelState.messages.isNotEmpty()) { + scope.launch { + listState.animateScrollToItem(panelState.messages.size - 1) + } + } + } + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { + Column { + Text( + text = panelState.title, + style = MaterialTheme.typography.titleLarge + ) + if (typingUsers.isNotEmpty()) { + TypingIndicator( + typingUsers = typingUsers.values.toList(), + modifier = Modifier.padding(top = 2.dp) + ) + } + } + }, + actions = { + if (panel.showCallButton()) { + IconButton(onClick = { /* TODO: Handle call */ }) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = "Call" + ) + } + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .pointerInput(Unit) { + detectTapGestures { + // Close context menu on outside tap + if (contextMenuState.isOpen) { + contextMenuState = contextMenuState.copy(isOpen = false) + } + } + } + ) { + if (panelState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Message list + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(vertical = 8.dp), + reverseLayout = false + ) { + items( + items = panelState.messages, + key = { it.id } + ) { message -> + val isAuthor = message.user_id == currentUserId + var tapPosition by remember { mutableStateOf(IntOffset(0, 0)) } + + MessageItem( + message = message, + isAuthor = isAuthor, + onLongPress = { + contextMenuState = ContextMenuState( + isOpen = true, + message = message, + position = tapPosition + ) + }, + onTapPosition = { position -> + tapPosition = position + } + ) + } + } + + // Chat input + ChatInput( + text = inputText, + onTextChange = { inputText = it }, + onSend = { text -> + if (editingMessage != null) { + scope.launch { + panel.handleEditMessage(editingMessage!!.id, text) + editingMessage = null + } + } else { + scope.launch { + panel.sendMessageWithImmediateDisplay(text, replyTo?.id) + replyTo = null + } + } + inputText = "" + }, + typingHandler = panel.getTypingHandler(), + replyTo = replyTo, + editingMessage = editingMessage, + onClearReply = { replyTo = null }, + onClearEdit = { editingMessage = null }, + modifier = Modifier.windowInsetsPadding(WindowInsets.ime) + ) + } + } + + // Context menu + MessageContextMenu( + state = contextMenuState, + isAuthor = contextMenuState.message?.user_id == currentUserId, + onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) }, + onReply = { message -> + replyTo = message + editingMessage = null + }, + onEdit = { message -> + editingMessage = message + inputText = message.content + replyTo = null + }, + onDelete = { message -> + scope.launch { + panel.handleDeleteMessage(message.id) + } + } + ) + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt new file mode 100644 index 0000000..03c3f40 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -0,0 +1,163 @@ +package ru.fromchat.ui.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import ru.fromchat.api.Message + +data class ContextMenuState( + val isOpen: Boolean = false, + val message: Message? = null, + val position: IntOffset = IntOffset(0, 0) +) + +@Composable +fun MessageContextMenu( + state: ContextMenuState, + isAuthor: Boolean, + onDismiss: () -> Unit, + onReply: (Message) -> Unit, + onEdit: (Message) -> Unit, + onDelete: (Message) -> Unit, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = state.isOpen, + enter = fadeIn(tween(200)) + scaleIn(initialScale = 0.9f, animationSpec = tween(200)), + exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.9f, animationSpec = tween(150)) + ) { + if (state.isOpen && state.message != null) { + Popup( + onDismissRequest = onDismiss, + alignment = Alignment.TopStart, + offset = state.position, + properties = PopupProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Surface( + modifier = modifier + .width(160.dp) + .shadow(8.dp, RoundedCornerShape(8.dp)), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest + ) { + Column { + // Reply button (always shown) + ContextMenuItem( + icon = Icons.AutoMirrored.Filled.Reply, + text = "Reply", + onClick = { + state.message?.let { onReply(it) } + onDismiss() + } + ) + + // Edit button (only for own messages) + if (isAuthor) { + HorizontalDivider() + ContextMenuItem( + icon = Icons.Default.Edit, + text = "Edit", + onClick = { + state.message?.let { onEdit(it) } + onDismiss() + } + ) + } + + // Delete button (only for own messages) + if (isAuthor) { + HorizontalDivider() + ContextMenuItem( + icon = Icons.Default.Delete, + text = "Delete", + onClick = { + state.message?.let { onDelete(it) } + onDismiss() + }, + isError = true + ) + } + } + } + } + } + } +} + +@Composable +private fun ContextMenuItem( + icon: ImageVector, + text: String, + onClick: () -> Unit, + isError: Boolean = false, + modifier: Modifier = Modifier +) { + val textColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + val iconColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + + Box( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.CenterStart + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = text, + tint = iconColor, + modifier = Modifier.size(20.dp) + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = textColor + ) + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt new file mode 100644 index 0000000..6a586d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -0,0 +1,240 @@ +package ru.fromchat.ui.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import ru.fromchat.api.Message +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Composable +fun MessageItem( + message: Message, + isAuthor: Boolean, + onLongPress: () -> Unit, + onTapPosition: (IntOffset) -> Unit = {}, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(300)) + slideInVertically( + initialOffsetY = { 20 }, + animationSpec = tween(300) + ), + exit = fadeOut(animationSpec = tween(200)) + slideOutVertically( + targetOffsetY = { -10 }, + animationSpec = tween(200) + ), + modifier = modifier + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { offset -> + onTapPosition( + IntOffset( + offset.x.toInt(), + offset.y.toInt() + ) + ) + onLongPress() + } + ) + }, + horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start + ) { + if (!isAuthor) { + // Profile picture + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + // TODO: Load profile picture + Text( + text = message.username.take(1).uppercase(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } + + Column( + modifier = Modifier + .weight(1f, fill = false) + .widthIn(max = 280.dp), + horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start + ) { + if (!isAuthor) { + // Username + Text( + text = message.username, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + + // Reply preview + message.reply_to?.let { replyTo -> + ReplyPreview( + replyTo = replyTo, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + + // Message bubble + Box( + modifier = Modifier + .clip( + RoundedCornerShape( + topStart = 20.dp, + topEnd = 20.dp, + bottomStart = if (isAuthor) 20.dp else 8.dp, + bottomEnd = if (isAuthor) 8.dp else 20.dp + ) + ) + .background( + if (isAuthor) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Column { + // Message content + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium, + color = if (isAuthor) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + + // Timestamp and edited indicator + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = formatTime(message.timestamp), + style = MaterialTheme.typography.labelSmall, + fontSize = 11.sp, + color = if (isAuthor) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + } + ) + if (message.is_edited) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "(edited)", + style = MaterialTheme.typography.labelSmall, + fontSize = 11.sp, + color = if (isAuthor) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + } + ) + } + } + } + } + } + + if (isAuthor) { + Spacer(modifier = Modifier.width(8.dp)) + } + } + } +} + +@Composable +private fun ReplyPreview( + replyTo: Message, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Column { + Text( + text = replyTo.username, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + fontSize = 11.sp + ) + Text( + text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + maxLines = 1 + ) + } + } +} + +@OptIn(ExperimentalTime::class) +private fun formatTime(timestamp: String): String { + return try { + val instant = Instant.parse(timestamp) + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + String.format( + "%02d:%02d", + localDateTime.hour, + localDateTime.minute + ) + } catch (e: Exception) { + "" + } +} + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt new file mode 100644 index 0000000..2903542 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -0,0 +1,148 @@ +package ru.fromchat.ui.chat + +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.decodeFromJsonElement +import ru.fromchat.api.ApiClient +import ru.fromchat.api.Message +import ru.fromchat.api.MessageDeletedData +import ru.fromchat.api.WebSocketMessage +import ru.fromchat.core.Logger + +class PublicChatPanel( + chatName: String, + currentUserId: Int?, + scope: CoroutineScope +) : ChatPanel( + id = "public-$chatName", + currentUserId = currentUserId, + scope = scope +) { + private val typingHandler = PublicChatTypingHandler(scope) + private var messagesLoaded = false + + init { + updateState { it.copy(title = chatName) } + } + + override suspend fun sendMessage(content: String, replyToId: Int?) { + ApiClient.sendMessage(content, replyToId) + } + + override suspend fun loadMessages() { + if (messagesLoaded) return + + setLoading(true) + try { + val response = ApiClient.getMessages(limit = 50) + if (response.messages.isNotEmpty()) { + clearMessages() + response.messages.forEach { message -> + addMessage(message) + } + } + setHasMoreMessages(false) // TODO: Implement has_more from API + messagesLoaded = true + } catch (e: Exception) { + // Handle error + } finally { + setLoading(false) + } + } + + override suspend fun loadMoreMessages() { + if (!_state.hasMoreMessages || _state.isLoadingMore) return + + val messages = _state.messages + if (messages.isEmpty()) return + + val oldestMessage = messages.first() + setLoadingMore(true) + try { + val response = ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id) + if (response.messages.isNotEmpty()) { + // Prepend older messages (they come in reverse chronological order) + updateState { currentState -> + currentState.copy( + messages = response.messages.reversed() + currentState.messages + ) + } + } + setHasMoreMessages(false) // TODO: Implement has_more from API + } catch (e: Exception) { + // Handle error + } finally { + setLoadingMore(false) + } + } + + override suspend fun handleWebSocketMessage(message: WebSocketMessage) { + val json = ApiClient.json + Logger.d("PublicChatPanel", "Handling WebSocket message: type=${message.type}") + when (message.type) { + "newMessage" -> { + val data = message.data ?: return + // Data is directly a Message, not wrapped + 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 + } + } + } + + addMessage(newMsg) + } + "messageEdited" -> { + val data = message.data ?: return + // Data is directly a Message + val editedMsg = json.decodeFromJsonElement(data) + updateMessage(editedMsg.id) { editedMsg } + } + "messageDeleted" -> { + val data = message.data ?: return + // Data is { message_id: Int } + val deletedData = json.decodeFromJsonElement(data) + removeMessage(deletedData.message_id) + } + "typing" -> { + // Typing status is handled in ChatScreen + } + } + } + + override suspend fun handleEditMessage(messageId: Int, content: String) { + ApiClient.editMessage(messageId, content) + } + + override suspend fun handleDeleteMessage(messageId: Int) { + // Remove immediately from UI + deleteMessageImmediately(messageId) + + // Send delete request + ApiClient.deleteMessage(messageId) + } + + override fun showCallButton(): Boolean = false + + override fun getTypingHandler(): TypingHandler = typingHandler +} + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt new file mode 100644 index 0000000..be3afe8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt @@ -0,0 +1,37 @@ +package ru.fromchat.ui.chat + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import ru.fromchat.api.ApiClient + +@Composable +fun PublicChatScreen() { + val scope = rememberCoroutineScope() + val currentUserId = ApiClient.user?.id + + // Create panel instance + val panel = remember { + PublicChatPanel( + chatName = "General Chat", + currentUserId = currentUserId, + scope = scope + ) + } + + // Load messages on first appear + LaunchedEffect(Unit) { + scope.launch { + panel.loadMessages() + } + } + + // Render with ChatScreen + ChatScreen( + panel = panel, + currentUserId = currentUserId + ) +} + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt new file mode 100644 index 0000000..721a8ab --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingHandler.kt @@ -0,0 +1,56 @@ +package ru.fromchat.ui.chat + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ru.fromchat.api.ApiClient + +/** + * Interface for handling typing indicators + */ +interface TypingHandler { + fun sendTyping() + fun stopTyping() +} + +/** + * Typing handler for public chat using WebSocket + */ +class PublicChatTypingHandler( + private val scope: CoroutineScope +) : TypingHandler { + private var stopTypingJob: Job? = null + + override fun sendTyping() { + scope.launch { + try { + ApiClient.sendTyping() + } catch (e: Exception) { + // Ignore errors + } + } + + // Cancel existing stop typing job + stopTypingJob?.cancel() + + // Schedule stop typing after delay + stopTypingJob = scope.launch { + delay(3000) // 3 seconds + stopTyping() + } + } + + override fun stopTyping() { + stopTypingJob?.cancel() + stopTypingJob = null + scope.launch { + try { + ApiClient.sendStopTyping() + } catch (e: Exception) { + // Ignore errors + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt new file mode 100644 index 0000000..c9a5315 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ru/fromchat/ui/chat/TypingIndicator.kt @@ -0,0 +1,129 @@ +package ru.fromchat.ui.chat + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.typing_many +import ru.fromchat.typing_single +import ru.fromchat.typing_two + +@Composable +fun TypingIndicator( + typingUsers: List, + modifier: Modifier = Modifier +) { + if (typingUsers.isEmpty()) return + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + TypingDots() + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formatTypingText(typingUsers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun formatTypingText(typingUsers: List): String { + return when (typingUsers.size) { + 0 -> "" + 1 -> stringResource(Res.string.typing_single, typingUsers[0]) + 2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1]) + else -> stringResource( + Res.string.typing_many, + typingUsers[0], + typingUsers[1], + typingUsers.size - 2 + ) + } +} + +@Composable +private fun TypingDots() { + val infiniteTransition = rememberInfiniteTransition(label = "typing_dots") + + val dot1Alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1400 + 0.3f at 0 + 1f at 200 + 0.3f at 400 + } + ), + label = "dot1" + ) + + val dot2Alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1400 + 0.3f at 200 + 1f at 400 + 0.3f at 600 + } + ), + label = "dot2" + ) + + val dot3Alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1400 + 0.3f at 400 + 1f at 600 + 0.3f at 800 + } + ), + label = "dot3" + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Dot(alpha = dot1Alpha) + Spacer(modifier = Modifier.width(2.dp)) + Dot(alpha = dot2Alpha) + Spacer(modifier = Modifier.width(2.dp)) + Dot(alpha = dot3Alpha) + } +} + +@Composable +private fun Dot(alpha: Float) { + Surface( + modifier = Modifier + .width(4.dp) + .height(4.dp) + .alpha(alpha), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary + ) {} +} +