mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Working public chat
This commit is contained in:
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user