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_connection">Connection error</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>
|
||||
|
||||
@@ -9,15 +9,15 @@ import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.logging.SIMPLE
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.bearerAuth
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.put
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.utils.failOnError
|
||||
import kotlin.concurrent.Volatile
|
||||
@@ -77,10 +77,13 @@ object ApiClient {
|
||||
}
|
||||
.failOnError()
|
||||
|
||||
suspend fun getMessages() =
|
||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||
http
|
||||
.get("${Config.getApiBaseUrl()}/get_messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
||||
parameter("limit", limit)
|
||||
beforeId?.let { parameter("before_id", it) }
|
||||
}
|
||||
.failOnError()
|
||||
.body<MessagesResponse>()
|
||||
@@ -95,23 +98,6 @@ object ApiClient {
|
||||
.failOnError()
|
||||
.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) {
|
||||
// Don't throw on logout errors, just try to logout
|
||||
@@ -123,4 +109,97 @@ object ApiClient {
|
||||
// Ignore logout errors
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket send helpers
|
||||
suspend fun sendMessage(content: String, replyToId: Int? = null) {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "sendMessage",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
),
|
||||
data = json.encodeToJsonElement(
|
||||
WebSocketSendMessageRequest(
|
||||
content = content,
|
||||
reply_to_id = replyToId
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun editMessage(messageId: Int, content: String) {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "editMessage",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
),
|
||||
data = json.encodeToJsonElement(
|
||||
WebSocketEditMessageRequest(
|
||||
message_id = messageId,
|
||||
content = content
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun deleteMessage(messageId: Int) {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "deleteMessage",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
),
|
||||
data = json.encodeToJsonElement(
|
||||
WebSocketDeleteMessageRequest(
|
||||
message_id = messageId
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun sendTyping() {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
try {
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "typing",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Silently ignore if WebSocket is not connected yet
|
||||
// Typing indicators are not critical
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendStopTyping() {
|
||||
val token = token ?: throw IllegalStateException("Not authenticated")
|
||||
try {
|
||||
WebSocketManager.send(
|
||||
WebSocketMessage(
|
||||
type = "stopTyping",
|
||||
credentials = WebSocketCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = token
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Silently ignore if WebSocket is not connected yet
|
||||
// Typing indicators are not critical
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,3 +99,57 @@ data class WebSocketMessage(
|
||||
val data: JsonElement? = null,
|
||||
val error: WebSocketError? = null
|
||||
)
|
||||
|
||||
// WebSocket message data types
|
||||
@Serializable
|
||||
data class NewMessageData(
|
||||
val message: Message
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageEditedData(
|
||||
val message: Message
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageDeletedData(
|
||||
val message_id: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TypingData(
|
||||
val userId: Int,
|
||||
val username: String
|
||||
)
|
||||
|
||||
// Batched updates message
|
||||
@Serializable
|
||||
data class UpdateItem(
|
||||
val type: String,
|
||||
val data: JsonElement? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdatesMessage(
|
||||
val type: String,
|
||||
val seq: Int,
|
||||
val updates: List<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.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.fromchat.core.config.Config
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.core.config.Config
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
object WebSocketManager {
|
||||
// Config
|
||||
@@ -47,6 +51,24 @@ object WebSocketManager {
|
||||
private var connecting: Boolean = false
|
||||
@Volatile private var session: DefaultClientWebSocketSession? = null
|
||||
|
||||
/**
|
||||
* Check if WebSocket is connected
|
||||
*/
|
||||
fun isConnected(): Boolean = session != null
|
||||
|
||||
/**
|
||||
* Wait for WebSocket connection with timeout
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
|
||||
if (session != null) return true
|
||||
val startTime = Clock.System.now().toEpochMilliseconds()
|
||||
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
|
||||
delay(100)
|
||||
}
|
||||
return session != null
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
Logger.d("WebSocketManager", "Connecting to WebSocket")
|
||||
if (connecting) return
|
||||
@@ -71,7 +93,21 @@ object WebSocketManager {
|
||||
val text = (frame as? Frame.Text)?.readText() ?: continue
|
||||
Logger.d("WebSocketManager", "Received payload: $text")
|
||||
try {
|
||||
val msg = json.decodeFromString<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) }
|
||||
_messages.emit(msg)
|
||||
} catch (e: Throwable) {
|
||||
@@ -95,10 +131,18 @@ object WebSocketManager {
|
||||
}
|
||||
|
||||
suspend fun send(message: WebSocketMessage) {
|
||||
val session = session
|
||||
if (session != null) {
|
||||
// Wait for connection if not connected yet
|
||||
if (session == null) {
|
||||
if (!waitForConnection(5000)) {
|
||||
Logger.w("WebSocketManager", "Cannot send message: no active session after waiting")
|
||||
throw IllegalStateException("No active WebSocket session")
|
||||
}
|
||||
}
|
||||
|
||||
val currentSession = session
|
||||
if (currentSession != null) {
|
||||
try {
|
||||
session.send(Frame.Text(json.encodeToString(message)))
|
||||
currentSession.send(Frame.Text(json.encodeToString(message)))
|
||||
} catch (e: Exception) {
|
||||
Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e)
|
||||
throw e
|
||||
|
||||
@@ -26,6 +26,7 @@ import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.ui.auth.LoginScreen
|
||||
import ru.fromchat.ui.auth.RegisterScreen
|
||||
import ru.fromchat.ui.chat.PublicChatScreen
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
|
||||
@@ -136,7 +137,9 @@ fun App() {
|
||||
)
|
||||
}
|
||||
|
||||
composable("chats/publicChat") { /* PublicChatScreen() */ }
|
||||
composable("chats/publicChat") {
|
||||
PublicChatScreen()
|
||||
}
|
||||
|
||||
composable("about") {
|
||||
AboutScreen()
|
||||
|
||||
@@ -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