Working public chat

This commit is contained in:
2025-12-08 16:45:32 +03:00
Unverified
parent fc9a25ad29
commit 3d7bb91b2c
15 changed files with 1851 additions and 26 deletions
@@ -0,0 +1,7 @@
<resources>
<!-- Typing Indicators -->
<string name="typing_single">%1$s печатает…</string>
<string name="typing_two">%1$s и %2$s печатают…</string>
<string name="typing_many">%1$s, %2$s и еще %3$d печатают…</string>
</resources>
@@ -62,4 +62,9 @@
<string name="error_invalid_credentials">Invalid username or password</string> <string name="error_invalid_credentials">Invalid username or password</string>
<string name="error_connection">Connection error</string> <string name="error_connection">Connection error</string>
<string name="error_unknown">An unknown error occurred</string> <string name="error_unknown">An unknown error occurred</string>
<!-- Typing Indicators -->
<string name="typing_single">%1$s is typing…</string>
<string name="typing_two">%1$s and %2$s are typing…</string>
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
</resources> </resources>
@@ -9,15 +9,15 @@ import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.SIMPLE import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.bearerAuth import io.ktor.client.request.bearerAuth
import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.utils.failOnError import ru.fromchat.utils.failOnError
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
@@ -77,10 +77,13 @@ object ApiClient {
} }
.failOnError() .failOnError()
suspend fun getMessages() = suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http http
.get("${Config.getApiBaseUrl()}/get_messages") { .get("${Config.getApiBaseUrl()}/get_messages") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
parameter("limit", limit)
beforeId?.let { parameter("before_id", it) }
} }
.failOnError() .failOnError()
.body<MessagesResponse>() .body<MessagesResponse>()
@@ -95,23 +98,6 @@ object ApiClient {
.failOnError() .failOnError()
.body<SendMessageResponse>() .body<SendMessageResponse>()
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<SendMessageResponse>()
suspend fun deleteMessage(messageId: Int) =
http
.delete("${Config.getApiBaseUrl()}/delete_message/$messageId") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
}
.failOnError()
suspend fun logout(authToken: String) { suspend fun logout(authToken: String) {
// Don't throw on logout errors, just try to logout // Don't throw on logout errors, just try to logout
@@ -123,4 +109,97 @@ object ApiClient {
// Ignore logout errors // 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
}
}
} }
@@ -98,4 +98,58 @@ data class WebSocketMessage(
val credentials: WebSocketCredentials? = null, val credentials: WebSocketCredentials? = null,
val data: JsonElement? = null, val data: JsonElement? = null,
val error: WebSocketError? = 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<UpdateItem>
)
// 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
) )
@@ -19,10 +19,14 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json 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.Logger
import ru.fromchat.core.config.Config
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
object WebSocketManager { object WebSocketManager {
// Config // Config
@@ -47,6 +51,24 @@ object WebSocketManager {
private var connecting: Boolean = false private var connecting: Boolean = false
@Volatile private var session: DefaultClientWebSocketSession? = null @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() { fun connect() {
Logger.d("WebSocketManager", "Connecting to WebSocket") Logger.d("WebSocketManager", "Connecting to WebSocket")
if (connecting) return if (connecting) return
@@ -71,7 +93,21 @@ object WebSocketManager {
val text = (frame as? Frame.Text)?.readText() ?: continue val text = (frame as? Frame.Text)?.readText() ?: continue
Logger.d("WebSocketManager", "Received payload: $text") Logger.d("WebSocketManager", "Received payload: $text")
try { try {
val msg = json.decodeFromString<WebSocketMessage>(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<WebSocketMessage>(text)
}
globalHandlers.forEach { it(msg) } globalHandlers.forEach { it(msg) }
_messages.emit(msg) _messages.emit(msg)
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -95,10 +131,18 @@ object WebSocketManager {
} }
suspend fun send(message: WebSocketMessage) { suspend fun send(message: WebSocketMessage) {
val session = session // Wait for connection if not connected yet
if (session != null) { 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 { try {
session.send(Frame.Text(json.encodeToString(message))) currentSession.send(Frame.Text(json.encodeToString(message)))
} catch (e: Exception) { } catch (e: Exception) {
Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e) Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e)
throw e throw e
@@ -26,6 +26,7 @@ import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.setup.ServerConfigScreen
@@ -136,7 +137,9 @@ fun App() {
) )
} }
composable("chats/publicChat") { /* PublicChatScreen() */ } composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") { composable("about") {
AboutScreen() AboutScreen()
@@ -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<kotlinx.coroutines.Job?>(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
)
}
}
}
}
@@ -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<Message> = 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<String, Pair<Job, Message>>()
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
}
@@ -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<Message?>(null) }
var editingMessage by rememberSaveable { mutableStateOf<Message?>(null) }
var contextMenuState by remember {
mutableStateOf(
ContextMenuState(
isOpen = false,
message = null,
position = IntOffset(0, 0)
)
)
}
var typingUsers by remember { mutableStateOf<Map<Int, String>>(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<ru.fromchat.api.UpdatesMessage>(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<TypingData>(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<TypingData>(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<TypingData>(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)
}
}
)
}
}
}
@@ -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
)
}
}
}
@@ -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) {
""
}
}
@@ -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<Message>(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<Message>(data)
updateMessage(editedMsg.id) { editedMsg }
}
"messageDeleted" -> {
val data = message.data ?: return
// Data is { message_id: Int }
val deletedData = json.decodeFromJsonElement<MessageDeletedData>(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
}
@@ -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
)
}
@@ -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
}
}
}
}
@@ -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<String>,
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>): 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
) {}
}