Fix WebSocket, refactor code

This commit is contained in:
2025-12-20 17:41:42 +03:00
Unverified
parent a59672103b
commit 8151e0ca0d
22 changed files with 185 additions and 147 deletions
+18 -1
View File
@@ -4,5 +4,22 @@ alwaysApply: true
When working with the mobile app: When working with the mobile app:
- Don't say anything when implementing the solution to my problem, just call the necessary tools and after that provide a concise result.
- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors. - After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors.
# ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
- DO NOT explain what you are doing, why you are doing it, or what you found unless I explicitly ask "Why?" or "Explain".
- NO introductory filler ("Sure", "I will", "Looking into it").
- NO status updates ("I've updated the file", "Build successful"). If the tool output shows success, that is enough.
- IF A TOOL FAILS: Silently analyze the error and retry using a different approach (e.g., use write_to_file if search_replace fails twice). Never mention the failure.
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
# Java Runtime Configuration (Android)
- **On Windows:**
- Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr`
- **On macOS:**
- Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
- This can be done by adding `export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home` to your shell configuration file (e.g., `~/.zshrc` or `~/.bash_profile`) and then running `source` on the file.
+2
View File
@@ -1,6 +1,7 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="ClassName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
@@ -17,6 +18,7 @@
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="FunctionName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
</inspection_tool> </inspection_tool>
+1 -1
View File
@@ -10,7 +10,7 @@ plugins {
kotlin("plugin.serialization") version "1.9.22" kotlin("plugin.serialization") version "1.9.22"
} }
kotlin { kotlin {
androidTarget() androidTarget()
compilerOptions { compilerOptions {
@@ -3,15 +3,5 @@ package ru.fromchat.api
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import okhttp3.Dns
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient { actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block)
return HttpClient(OkHttp) {
engine {
config {
dns(Dns.SYSTEM)
}
}
block()
}
}
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config cleartextTrafficPermitted="true"> <base-config cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration">
<trust-anchors> <trust-anchors>
<certificates src="system" /> <certificates src="system" />
<certificates src="user" />
</trust-anchors> </trust-anchors>
</base-config> </base-config>
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
@@ -11,10 +11,4 @@
<domain includeSubdomains="true">127.0.0.1</domain> <domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain> <domain includeSubdomains="true">10.0.2.2</domain>
</domain-config> </domain-config>
<domain-config>
<domain includeSubdomains="true">fromchat.ru</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config> </network-security-config>
@@ -41,7 +41,7 @@
<string name="server_config_title">Server Configuration</string> <string name="server_config_title">Server Configuration</string>
<string name="server_config_subtitle">Enter server details to connect</string> <string name="server_config_subtitle">Enter server details to connect</string>
<string name="server_url_label">Server URL</string> <string name="server_url_label">Server URL</string>
<string name="server_url_hint">fromchat.ru</string> <string name="server_url_hint">example.com</string>
<string name="https_enabled">Use HTTPS</string> <string name="https_enabled">Use HTTPS</string>
<string name="save_continue">Save &amp; Continue</string> <string name="save_continue">Save &amp; Continue</string>
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging 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.plugins.websocket.pingInterval
import io.ktor.client.request.bearerAuth import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
@@ -21,6 +22,7 @@ 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
import kotlin.time.Duration.Companion.milliseconds
/** /**
* Creates a platform-specific HTTP client that supports WebSockets * Creates a platform-specific HTTP client that supports WebSockets
@@ -47,7 +49,9 @@ object ApiClient {
level = LogLevel.INFO level = LogLevel.INFO
} }
install(WebSockets) install(WebSockets) {
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
}
} }
@Volatile @Volatile
@@ -58,7 +62,7 @@ object ApiClient {
suspend fun login(request: LoginRequest) = suspend fun login(request: LoginRequest) =
http http
.post("${Config.getApiBaseUrl()}/login") { .post("${Config.apiBaseUrl}/login") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") }) setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
} }
@@ -71,7 +75,7 @@ object ApiClient {
suspend fun register(request: RegisterRequest) = suspend fun register(request: RegisterRequest) =
http http
.post("${Config.getApiBaseUrl()}/register") { .post("${Config.apiBaseUrl}/register") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -79,7 +83,7 @@ object ApiClient {
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) = suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http http
.get("${Config.getApiBaseUrl()}/get_messages") { .get("${Config.apiBaseUrl}/get_messages") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
bearerAuth(token ?: throw IllegalStateException("Not authenticated")) bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
parameter("limit", limit) parameter("limit", limit)
@@ -90,7 +94,7 @@ object ApiClient {
suspend fun send(message: String) = suspend fun send(message: String) =
http http
.post("${Config.getApiBaseUrl()}/send_message") { .post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
bearerAuth(token!!) bearerAuth(token!!)
setBody(SendMessageRequest(message)) setBody(SendMessageRequest(message))
@@ -102,7 +106,7 @@ object ApiClient {
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
try { try {
http.get("${Config.getApiBaseUrl()}/logout") { http.get("${Config.apiBaseUrl}/logout") {
bearerAuth(authToken) bearerAuth(authToken)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -37,3 +37,7 @@ data class TypingUpdateData(
val username: String val username: String
) )
@Serializable
data class WebSocketAuthMessage(
val token: String
)
@@ -47,38 +47,44 @@ object WebSocketManager {
} }
// State // State
@Volatile @Volatile private var connecting = false
private var connecting: Boolean = false
@Volatile private var session: DefaultClientWebSocketSession? = null @Volatile private var session: DefaultClientWebSocketSession? = null
/** /**
* Check if WebSocket is connected * Check if WebSocket is connected
*/ */
fun isConnected(): Boolean = session != null val isConnected get() = session != null
/** /**
* Wait for WebSocket connection with timeout * Wait for WebSocket connection with timeout
*/ */
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean { suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
Logger.d("WebSocketManager", "waitForConnection: session=${session != null}, connecting=$connecting")
if (session != null) return true if (session != null) return true
val startTime = Clock.System.now().toEpochMilliseconds() val startTime = Clock.System.now().toEpochMilliseconds()
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) { while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
delay(100) delay(100)
} }
Logger.d("WebSocketManager", "waitForConnection finished: session=${session != null}")
return session != null return session != null
} }
fun connect() { fun connect() {
Logger.d("WebSocketManager", "Connecting to WebSocket") Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting")
if (connecting) return if (connecting) {
Logger.d("WebSocketManager", "connect() ignored: already connecting")
return
}
connecting = true connecting = true
Logger.d("WebSocketManager", "connecting set to true")
scope.launch { scope.launch {
while (isActive) { while (isActive) {
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
try { try {
val wsUrl = Config.getWebSocketUrl() val wsUrl = Config.webSocketUrl
Logger.d("WebSocketManager", "Connecting to: $wsUrl") Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl")
ApiClient.http.webSocket( ApiClient.http.webSocket(
method = HttpMethod.Get, method = HttpMethod.Get,
request = { request = {
@@ -87,25 +93,38 @@ object WebSocketManager {
) { ) {
session = this session = this
connecting = false connecting = false
Logger.d("WebSocketManager", "WebSocket connected. connecting set to false")
// Send ping message immediately after connection for authentication
ApiClient.token?.let {
Logger.d("WebSocketManager", "Sending WebSocket ping for authentication")
send(WebSocketMessage(type = "ping", credentials = WebSocketCredentials(scheme = "Bearer", credentials = it)))
}
Logger.d("WebSocketManager", "WebSocket connected")
for (frame in incoming) { for (frame in incoming) {
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 {
// Check if this is an "updates" message - it has a different structure
val jsonTree = json.parseToJsonElement(text) val jsonTree = json.parseToJsonElement(text)
val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
val msg = if (messageType == "updates") { val msg = when (messageType) {
// Wrap in WebSocketMessage with the entire JSON tree as data "updates" -> {
WebSocketMessage( WebSocketMessage(
type = "updates", type = "updates",
data = jsonTree data = jsonTree
) )
} else { }
// Parse as regular WebSocketMessage "typing", "stopTyping" -> {
json.decodeFromString<WebSocketMessage>(text) // These messages are expected to be direct, without additional data in the web client
WebSocketMessage(
type = messageType,
data = jsonTree.jsonObject["data"] // Extract data if present
)
}
else -> {
json.decodeFromString<WebSocketMessage>(text)
}
} }
globalHandlers.forEach { it(msg) } globalHandlers.forEach { it(msg) }
@@ -117,12 +136,13 @@ object WebSocketManager {
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w("WebSocketManager", "An error occurred: ${e.message}", e) Logger.w("WebSocketManager", "An error occurred during WebSocket connection: ${e.message}", e)
connecting = false connecting = false
session = null session = null
Logger.d("WebSocketManager", "Reconnecting in 3 seconds...")
delay(3000) delay(3000)
} finally { } finally {
Logger.w("WebSocketManager", "WebSocket disconnected") Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false")
session = null session = null
connecting = false connecting = false
} }
@@ -190,6 +210,15 @@ object WebSocketManager {
} }
fun shutdown() { fun shutdown() {
Logger.d("WebSocketManager", "shutdown() called. Cancelling scope.")
scope.cancel() scope.cancel()
} }
fun disconnect() {
Logger.d("WebSocketManager", "disconnect() called. current session=${session != null}")
session?.cancel() // Close the WebSocket session
session = null
connecting = false
Logger.d("WebSocketManager", "Disconnected. session set to null, connecting set to false")
}
} }
@@ -13,6 +13,9 @@ object Config {
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null) private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow() val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
private val config
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
/** /**
* Initialize configuration by loading from storage * Initialize configuration by loading from storage
*/ */
@@ -31,25 +34,17 @@ object Config {
/** /**
* Get API base URL based on current server configuration * Get API base URL based on current server configuration
*/ */
fun getApiBaseUrl(): String { val apiBaseUrl
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true) get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
val protocol = if (config.httpsEnabled) "https" else "http"
return "$protocol://${config.serverUrl}/api"
}
/** /**
* Get WebSocket URL based on current server configuration * Get WebSocket URL based on current server configuration
*/ */
fun getWebSocketUrl(): String { val webSocketUrl
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true) get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
val protocol = if (config.httpsEnabled) "wss" else "ws"
return "$protocol://${config.serverUrl}/api/chat/ws"
}
/** /**
* Checks if server configuration exists * Checks if server configuration exists
*/ */
suspend fun hasServerConfig(): Boolean { suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
return ServerConfigStorage.hasConfiguration()
}
} }
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -15,7 +16,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -61,6 +66,29 @@ fun App() {
} }
} }
// Observe lifecycle events to manage WebSocket connection
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
// Connect WebSocket when app comes to foreground
WebSocketManager.connect()
}
Lifecycle.Event.ON_PAUSE -> {
// Disconnect WebSocket when app goes to background
WebSocketManager.disconnect()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
FromChatTheme { FromChatTheme {
val navController = rememberNavController() val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400) val animationSpec = tween<IntOffset>(400)
@@ -111,8 +139,6 @@ fun App() {
composable("login") { composable("login") {
LoginScreen( LoginScreen(
onLoginSuccess = { onLoginSuccess = {
// Ensure WebSocket is connected after login
WebSocketManager.connect()
navController.navigate("chat") { navController.navigate("chat") {
popUpTo("login") { inclusive = true } popUpTo("login") { inclusive = true }
} }
@@ -45,7 +45,7 @@ fun Avatar(
val fullUrl = if (profilePictureUrl.startsWith("http")) { val fullUrl = if (profilePictureUrl.startsWith("http")) {
profilePictureUrl profilePictureUrl
} else { } else {
"${Config.getApiBaseUrl()}$profilePictureUrl" "${Config.apiBaseUrl}$profilePictureUrl"
} }
AsyncImage( AsyncImage(
@@ -22,7 +22,8 @@ data class ChatPanelState(
val messages: List<Message> = emptyList(), val messages: List<Message> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val hasMoreMessages: Boolean = false, val hasMoreMessages: Boolean = false,
val isLoadingMore: Boolean = false val isLoadingMore: Boolean = false,
val typingUsers: List<TypingUser> = emptyList()
) )
/** /**
@@ -61,9 +61,9 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.TypingData
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
@@ -80,10 +80,10 @@ fun ChatScreen(
// Observe state changes // Observe state changes
LaunchedEffect(panel) { LaunchedEffect(panel) {
panel.setOnStateChange { newState -> panel.setOnStateChange { newState ->
Logger.d("ChatScreen", "State change callback received: messages=${newState.messages.size}") Logger.d("ChatScreen", "State change callback received: messages=${newState.messages.size}, typingUsers=${newState.typingUsers.map { it.username }}")
// Force state update to trigger recomposition // Force state update to trigger recomposition
panelState = newState.copy() // Ensure new instance panelState = newState.copy() // Ensure new instance
Logger.d("ChatScreen", "panelState updated: messages=${panelState.messages.size}") Logger.d("ChatScreen", "panelState updated: messages=${panelState.messages.size}, typingUsers=${panelState.typingUsers.map { it.username }}")
} }
// Initial state // Initial state
panelState = panel.getState() panelState = panel.getState()
@@ -100,6 +100,11 @@ fun ChatScreen(
val navController = LocalNavController.current val navController = LocalNavController.current
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState(blurEnabled = true)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
// UI state // UI state
var inputText by rememberSaveable { mutableStateOf("") } var inputText by rememberSaveable { mutableStateOf("") }
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) } var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
@@ -113,8 +118,6 @@ fun ChatScreen(
) )
) )
} }
var typingUsers by remember { mutableStateOf<Map<Int, String>>(emptyMap()) }
// Collect WebSocket messages // Collect WebSocket messages
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
WebSocketManager.messages.collect { message -> WebSocketManager.messages.collect { message ->
@@ -132,7 +135,7 @@ fun ChatScreen(
val json = ApiClient.json val json = ApiClient.json
try { try {
Logger.d("ChatScreen", "Parsing updates message") Logger.d("ChatScreen", "Parsing updates message")
val updatesMessage = json.decodeFromJsonElement<ru.fromchat.api.UpdatesMessage>(data) val updatesMessage = json.decodeFromJsonElement<WebSocketUpdatesData>(data)
Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates") Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates")
// Process each update in the batch // Process each update in the batch
updatesMessage.updates.forEach { update -> updatesMessage.updates.forEach { update ->
@@ -142,7 +145,7 @@ fun ChatScreen(
data = update.data data = update.data
) )
when (update.type) { when (update.type) {
"newMessage", "messageEdited", "messageDeleted" -> { "newMessage", "messageEdited", "messageDeleted", "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}") Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
scope.launch { scope.launch {
try { try {
@@ -152,23 +155,6 @@ fun ChatScreen(
} }
} }
} }
"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) { } catch (e: Exception) {
@@ -181,18 +167,8 @@ fun ChatScreen(
panel.handleWebSocketMessage(message) panel.handleWebSocketMessage(message)
} }
} }
"typing" -> { else -> {
val data = message.data ?: return@collect Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
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
}
}
} }
} }
} }
@@ -220,7 +196,7 @@ fun ChatScreen(
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
AnimatedContent( AnimatedContent(
targetState = typingUsers.isNotEmpty(), targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = { transitionSpec = {
fadeIn() togetherWith fadeOut() fadeIn() togetherWith fadeOut()
}, },
@@ -228,7 +204,7 @@ fun ChatScreen(
) { hasTyping -> ) { hasTyping ->
if (hasTyping) { if (hasTyping) {
TypingIndicator( TypingIndicator(
typingUsers = typingUsers.values.toList(), typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp) modifier = Modifier.padding(top = 2.dp)
) )
} else { } else {
@@ -1,7 +1,7 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.MessageDeletedData import ru.fromchat.api.MessageDeletedData
@@ -25,6 +25,13 @@ class PublicChatPanel(
init { init {
updateState { it.copy(title = chatName) } updateState { it.copy(title = chatName) }
// Observe typing users from the handler and update panel state
scope.launch {
typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users) }
}
}
} }
private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) { private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
@@ -84,12 +91,12 @@ class PublicChatPanel(
} }
} }
private suspend fun handleSingleUpdate(updateMessage: WebSocketMessage) { private fun handleSingleUpdate(updateMessage: WebSocketMessage) {
val json = ApiClient.json val json = ApiClient.json
when (updateMessage.type) { when (updateMessage.type) {
"newMessage" -> { "newMessage" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val newMsg = json.decodeFromJsonElement<Message>(data) val newMsg = json.decodeFromJsonElement(Message.serializer(), data)
Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}") Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}")
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) { if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
@@ -100,27 +107,29 @@ class PublicChatPanel(
} }
"messageEdited" -> { "messageEdited" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement<Message>(data) val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
updateMessage(editedMsg.id) { editedMsg } updateMessage(editedMsg.id) { editedMsg }
} }
"messageDeleted" -> { "messageDeleted" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement<MessageDeletedData>(data) val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
removeMessage(deletedData.message_id) removeMessage(deletedData.message_id)
} }
"reactionUpdate" -> { "reactionUpdate" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement<ReactionUpdateData>(data) val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate) handleReactionUpdate(reactionUpdate)
} }
"typing" -> { "typing" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement<TypingUpdateData>(data) val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
Logger.d("PublicChatPanel", "Received typing event for user: ${typingData.username}")
typingHandler.handleTypingEvent(typingData.userId, typingData.username) typingHandler.handleTypingEvent(typingData.userId, typingData.username)
} }
"stopTyping" -> { "stopTyping" -> {
val data = updateMessage.data ?: return val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement<TypingUpdateData>(data) val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
Logger.d("PublicChatPanel", "Received stopTyping event for user: ${typingData.username}")
typingHandler.handleStopTypingEvent(typingData.userId) typingHandler.handleStopTypingEvent(typingData.userId)
} }
"statusUpdate" -> { "statusUpdate" -> {
@@ -143,7 +152,7 @@ class PublicChatPanel(
if (message.type == "updates") { if (message.type == "updates") {
val json = ApiClient.json val json = ApiClient.json
val data = message.data ?: return val data = message.data ?: return
val updatesData = json.decodeFromJsonElement<WebSocketUpdatesData>(data) val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
Logger.d("PublicChatPanel", "Received ${updatesData.updates.size} batched updates (seq: ${updatesData.seq})") Logger.d("PublicChatPanel", "Received ${updatesData.updates.size} batched updates (seq: ${updatesData.seq})")
updatesData.updates.forEach { update -> updatesData.updates.forEach { update ->
handleSingleUpdate(update) handleSingleUpdate(update)
@@ -170,4 +179,3 @@ class PublicChatPanel(
override fun getTypingHandler(): TypingHandler = typingHandler override fun getTypingHandler(): TypingHandler = typingHandler
} }
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -22,6 +23,7 @@ interface TypingHandler {
val typingUsers: StateFlow<List<TypingUser>> val typingUsers: StateFlow<List<TypingUser>>
} }
@Serializable
data class TypingUser( data class TypingUser(
val userId: Int, val userId: Int,
val username: String val username: String
@@ -63,8 +63,8 @@ fun ServerConfigScreen() {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available // Load existing config if available
var serverUrl by remember { mutableStateOf("fromchat.ru") } var serverUrl by remember { mutableStateOf("") }
var httpsEnabled by remember { mutableStateOf(true) } var httpsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val config = Config.serverConfig.value val config = Config.serverConfig.value
+5 -6
View File
@@ -1,26 +1,25 @@
[versions] [versions]
agp = "8.13.2" agp = "8.13.2"
androidx-activityCompose = "1.12.1" androidx-activityCompose = "1.12.2"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.17.0" androidx-core-ktx = "1.17.0"
androidx-lifecycle = "2.9.6"
coilCompose = "3.3.0" coilCompose = "3.3.0"
compose-multiplatform = "1.9.3" compose-multiplatform = "1.9.3"
#noinspection NewerVersionAvailable #noinspection NewerVersionAvailable
constraintlayout = "0.6.1-shaded" constraintlayout = "0.6.1-shaded"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
haze = "1.7.1" haze = "1.7.1"
kotlin = "2.2.21" kotlin = "2.3.0"
adaptiveAndroid = "1.2.0" adaptiveAndroid = "1.2.0"
kotlinStdlib = "2.2.21" kotlinStdlib = "2.3.0"
biometric = "1.4.0-alpha04" biometric = "1.4.0-alpha05"
gson = "2.13.2" gson = "2.13.2"
kotlinxIoBytestring = "0.8.2" kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2" kotlinxIoCore = "0.8.2"
kotlinxSerializationJson = "1.9.0" kotlinxSerializationJson = "1.9.0"
material = "1.13.0" material = "1.13.0"
activityKtx = "1.12.1" activityKtx = "1.12.2"
navigationCompose = "2.9.1" navigationCompose = "2.9.1"
datastore = "1.2.0" datastore = "1.2.0"
ktor = "3.3.3" ktor = "3.3.3"
+1 -1
View File
@@ -1,6 +1,6 @@
#Sat Sep 06 13:57:07 MSK 2025 #Sat Sep 06 13:57:07 MSK 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+5
View File
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.androidKotlinMultiplatformLibrary)
@@ -14,6 +16,9 @@ kotlin {
namespace = "com.pr0gramm3r101.utils" namespace = "com.pr0gramm3r101.utils"
compileSdk = 36 compileSdk = 36
minSdk = 24 minSdk = 24
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
androidResources.enable = true
} }
listOf( listOf(
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Dialog" parent="Theme.Material3.DayNight.NoActionBar" tools:keep="@style/Theme_Dialog">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="windowActionBar">false</item>
<item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>
@@ -43,7 +43,7 @@ object ServerConfigStorage {
} }
suspend fun getConfig(): ServerConfigData { suspend fun getConfig(): ServerConfigData {
val url = getServerUrl() ?: "fromchat.ru" val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage")
val https = getHttpsEnabled() ?: true val https = getHttpsEnabled() ?: true
return ServerConfigData(url, https) return ServerConfigData(url, https)
} }