mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix WebSocket, refactor code
This commit is contained in:
@@ -4,5 +4,22 @@ alwaysApply: true
|
||||
|
||||
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.
|
||||
|
||||
# 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
@@ -1,6 +1,7 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<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">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
@@ -17,6 +18,7 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</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">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
|
||||
@@ -3,15 +3,5 @@ package ru.fromchat.api
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import okhttp3.Dns
|
||||
|
||||
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient {
|
||||
return HttpClient(OkHttp) {
|
||||
engine {
|
||||
config {
|
||||
dns(Dns.SYSTEM)
|
||||
}
|
||||
}
|
||||
block()
|
||||
}
|
||||
}
|
||||
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block)
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<base-config cleartextTrafficPermitted="true"
|
||||
tools:ignore="InsecureBaseConfiguration">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
@@ -11,10 +11,4 @@
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
</domain-config>
|
||||
<domain-config>
|
||||
<domain includeSubdomains="true">fromchat.ru</domain>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<string name="server_config_title">Server Configuration</string>
|
||||
<string name="server_config_subtitle">Enter server details to connect</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="save_continue">Save & 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.SIMPLE
|
||||
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.get
|
||||
import io.ktor.client.request.parameter
|
||||
@@ -21,6 +22,7 @@ import kotlinx.serialization.json.encodeToJsonElement
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.utils.failOnError
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Creates a platform-specific HTTP client that supports WebSockets
|
||||
@@ -47,7 +49,9 @@ object ApiClient {
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
|
||||
install(WebSockets)
|
||||
install(WebSockets) {
|
||||
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
@@ -58,7 +62,7 @@ object ApiClient {
|
||||
|
||||
suspend fun login(request: LoginRequest) =
|
||||
http
|
||||
.post("${Config.getApiBaseUrl()}/login") {
|
||||
.post("${Config.apiBaseUrl}/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
||||
}
|
||||
@@ -71,7 +75,7 @@ object ApiClient {
|
||||
|
||||
suspend fun register(request: RegisterRequest) =
|
||||
http
|
||||
.post("${Config.getApiBaseUrl()}/register") {
|
||||
.post("${Config.apiBaseUrl}/register") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
@@ -79,7 +83,7 @@ object ApiClient {
|
||||
|
||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||
http
|
||||
.get("${Config.getApiBaseUrl()}/get_messages") {
|
||||
.get("${Config.apiBaseUrl}/get_messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
||||
parameter("limit", limit)
|
||||
@@ -90,7 +94,7 @@ object ApiClient {
|
||||
|
||||
suspend fun send(message: String) =
|
||||
http
|
||||
.post("${Config.getApiBaseUrl()}/send_message") {
|
||||
.post("${Config.apiBaseUrl}/send_message") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token!!)
|
||||
setBody(SendMessageRequest(message))
|
||||
@@ -102,7 +106,7 @@ object ApiClient {
|
||||
suspend fun logout(authToken: String) {
|
||||
// Don't throw on logout errors, just try to logout
|
||||
try {
|
||||
http.get("${Config.getApiBaseUrl()}/logout") {
|
||||
http.get("${Config.apiBaseUrl}/logout") {
|
||||
bearerAuth(authToken)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -37,3 +37,7 @@ data class TypingUpdateData(
|
||||
val username: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WebSocketAuthMessage(
|
||||
val token: String
|
||||
)
|
||||
@@ -47,38 +47,44 @@ object WebSocketManager {
|
||||
}
|
||||
|
||||
// State
|
||||
@Volatile
|
||||
private var connecting: Boolean = false
|
||||
@Volatile private var connecting = false
|
||||
@Volatile private var session: DefaultClientWebSocketSession? = null
|
||||
|
||||
/**
|
||||
* Check if WebSocket is connected
|
||||
*/
|
||||
fun isConnected(): Boolean = session != null
|
||||
val isConnected get() = session != null
|
||||
|
||||
/**
|
||||
* Wait for WebSocket connection with timeout
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
|
||||
Logger.d("WebSocketManager", "waitForConnection: session=${session != null}, connecting=$connecting")
|
||||
if (session != null) return true
|
||||
val startTime = Clock.System.now().toEpochMilliseconds()
|
||||
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
|
||||
delay(100)
|
||||
}
|
||||
Logger.d("WebSocketManager", "waitForConnection finished: session=${session != null}")
|
||||
return session != null
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
Logger.d("WebSocketManager", "Connecting to WebSocket")
|
||||
if (connecting) return
|
||||
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting")
|
||||
if (connecting) {
|
||||
Logger.d("WebSocketManager", "connect() ignored: already connecting")
|
||||
return
|
||||
}
|
||||
connecting = true
|
||||
Logger.d("WebSocketManager", "connecting set to true")
|
||||
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
|
||||
try {
|
||||
val wsUrl = Config.getWebSocketUrl()
|
||||
Logger.d("WebSocketManager", "Connecting to: $wsUrl")
|
||||
val wsUrl = Config.webSocketUrl
|
||||
Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl")
|
||||
ApiClient.http.webSocket(
|
||||
method = HttpMethod.Get,
|
||||
request = {
|
||||
@@ -87,26 +93,39 @@ object WebSocketManager {
|
||||
) {
|
||||
session = this
|
||||
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) {
|
||||
val text = (frame as? Frame.Text)?.readText() ?: continue
|
||||
Logger.d("WebSocketManager", "Received payload: $text")
|
||||
try {
|
||||
// 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
|
||||
val msg = when (messageType) {
|
||||
"updates" -> {
|
||||
WebSocketMessage(
|
||||
type = "updates",
|
||||
data = jsonTree
|
||||
)
|
||||
} else {
|
||||
// Parse as regular WebSocketMessage
|
||||
}
|
||||
"typing", "stopTyping" -> {
|
||||
// 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) }
|
||||
_messages.emit(msg)
|
||||
@@ -117,12 +136,13 @@ object WebSocketManager {
|
||||
}
|
||||
}
|
||||
} 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
|
||||
session = null
|
||||
Logger.d("WebSocketManager", "Reconnecting in 3 seconds...")
|
||||
delay(3000)
|
||||
} finally {
|
||||
Logger.w("WebSocketManager", "WebSocket disconnected")
|
||||
Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false")
|
||||
session = null
|
||||
connecting = false
|
||||
}
|
||||
@@ -190,6 +210,15 @@ object WebSocketManager {
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
Logger.d("WebSocketManager", "shutdown() called. Cancelling scope.")
|
||||
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)
|
||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||
|
||||
private val config
|
||||
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
|
||||
|
||||
/**
|
||||
* Initialize configuration by loading from storage
|
||||
*/
|
||||
@@ -31,25 +34,17 @@ object Config {
|
||||
/**
|
||||
* Get API base URL based on current server configuration
|
||||
*/
|
||||
fun getApiBaseUrl(): String {
|
||||
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
|
||||
val protocol = if (config.httpsEnabled) "https" else "http"
|
||||
return "$protocol://${config.serverUrl}/api"
|
||||
}
|
||||
val apiBaseUrl
|
||||
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
|
||||
|
||||
/**
|
||||
* Get WebSocket URL based on current server configuration
|
||||
*/
|
||||
fun getWebSocketUrl(): String {
|
||||
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
|
||||
val protocol = if (config.httpsEnabled) "wss" else "ws"
|
||||
return "$protocol://${config.serverUrl}/api/chat/ws"
|
||||
}
|
||||
val webSocketUrl
|
||||
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
|
||||
|
||||
/**
|
||||
* Checks if server configuration exists
|
||||
*/
|
||||
suspend fun hasServerConfig(): Boolean {
|
||||
return ServerConfigStorage.hasConfiguration()
|
||||
}
|
||||
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -15,7 +16,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
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.compose.NavHost
|
||||
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 {
|
||||
val navController = rememberNavController()
|
||||
val animationSpec = tween<IntOffset>(400)
|
||||
@@ -111,8 +139,6 @@ fun App() {
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
// Ensure WebSocket is connected after login
|
||||
WebSocketManager.connect()
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ fun Avatar(
|
||||
val fullUrl = if (profilePictureUrl.startsWith("http")) {
|
||||
profilePictureUrl
|
||||
} else {
|
||||
"${Config.getApiBaseUrl()}$profilePictureUrl"
|
||||
"${Config.apiBaseUrl}$profilePictureUrl"
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
|
||||
@@ -22,7 +22,8 @@ data class ChatPanelState(
|
||||
val messages: List<Message> = emptyList(),
|
||||
val isLoading: 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.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.api.WebSocketUpdatesData
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.core.Logger
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
@@ -80,10 +80,10 @@ fun ChatScreen(
|
||||
// Observe state changes
|
||||
LaunchedEffect(panel) {
|
||||
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
|
||||
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
|
||||
panelState = panel.getState()
|
||||
@@ -100,6 +100,11 @@ fun ChatScreen(
|
||||
val navController = LocalNavController.current
|
||||
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
|
||||
var inputText by rememberSaveable { mutableStateOf("") }
|
||||
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
|
||||
LaunchedEffect(Unit) {
|
||||
WebSocketManager.messages.collect { message ->
|
||||
@@ -132,7 +135,7 @@ fun ChatScreen(
|
||||
val json = ApiClient.json
|
||||
try {
|
||||
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")
|
||||
// Process each update in the batch
|
||||
updatesMessage.updates.forEach { update ->
|
||||
@@ -142,7 +145,7 @@ fun ChatScreen(
|
||||
data = update.data
|
||||
)
|
||||
when (update.type) {
|
||||
"newMessage", "messageEdited", "messageDeleted" -> {
|
||||
"newMessage", "messageEdited", "messageDeleted", "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
|
||||
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
|
||||
scope.launch {
|
||||
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) {
|
||||
@@ -181,18 +167,8 @@ fun ChatScreen(
|
||||
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
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +196,7 @@ fun ChatScreen(
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = typingUsers.isNotEmpty(),
|
||||
targetState = currentTypingUsers.isNotEmpty(),
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
@@ -228,7 +204,7 @@ fun ChatScreen(
|
||||
) { hasTyping ->
|
||||
if (hasTyping) {
|
||||
TypingIndicator(
|
||||
typingUsers = typingUsers.values.toList(),
|
||||
typingUsers = currentTypingUsers.map { it.username },
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.Message
|
||||
import ru.fromchat.api.MessageDeletedData
|
||||
@@ -25,6 +25,13 @@ class PublicChatPanel(
|
||||
|
||||
init {
|
||||
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) {
|
||||
@@ -84,12 +91,12 @@ class PublicChatPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSingleUpdate(updateMessage: WebSocketMessage) {
|
||||
private fun handleSingleUpdate(updateMessage: WebSocketMessage) {
|
||||
val json = ApiClient.json
|
||||
when (updateMessage.type) {
|
||||
"newMessage" -> {
|
||||
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)}")
|
||||
|
||||
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
|
||||
@@ -100,27 +107,29 @@ class PublicChatPanel(
|
||||
}
|
||||
"messageEdited" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val editedMsg = json.decodeFromJsonElement<Message>(data)
|
||||
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
||||
updateMessage(editedMsg.id) { editedMsg }
|
||||
}
|
||||
"messageDeleted" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val deletedData = json.decodeFromJsonElement<MessageDeletedData>(data)
|
||||
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
|
||||
removeMessage(deletedData.message_id)
|
||||
}
|
||||
"reactionUpdate" -> {
|
||||
val data = updateMessage.data ?: return
|
||||
val reactionUpdate = json.decodeFromJsonElement<ReactionUpdateData>(data)
|
||||
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
|
||||
handleReactionUpdate(reactionUpdate)
|
||||
}
|
||||
"typing" -> {
|
||||
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)
|
||||
}
|
||||
"stopTyping" -> {
|
||||
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)
|
||||
}
|
||||
"statusUpdate" -> {
|
||||
@@ -143,7 +152,7 @@ class PublicChatPanel(
|
||||
if (message.type == "updates") {
|
||||
val json = ApiClient.json
|
||||
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})")
|
||||
updatesData.updates.forEach { update ->
|
||||
handleSingleUpdate(update)
|
||||
@@ -170,4 +179,3 @@ class PublicChatPanel(
|
||||
|
||||
override fun getTypingHandler(): TypingHandler = typingHandler
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.fromchat.api.ApiClient
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -22,6 +23,7 @@ interface TypingHandler {
|
||||
val typingUsers: StateFlow<List<TypingUser>>
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TypingUser(
|
||||
val userId: Int,
|
||||
val username: String
|
||||
|
||||
@@ -63,8 +63,8 @@ fun ServerConfigScreen() {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
// Load existing config if available
|
||||
var serverUrl by remember { mutableStateOf("fromchat.ru") }
|
||||
var httpsEnabled by remember { mutableStateOf(true) }
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
var httpsEnabled by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val config = Config.serverConfig.value
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
androidx-activityCompose = "1.12.1"
|
||||
androidx-activityCompose = "1.12.2"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core-ktx = "1.17.0"
|
||||
androidx-lifecycle = "2.9.6"
|
||||
coilCompose = "3.3.0"
|
||||
compose-multiplatform = "1.9.3"
|
||||
#noinspection NewerVersionAvailable
|
||||
constraintlayout = "0.6.1-shaded"
|
||||
coreSplashscreen = "1.2.0"
|
||||
haze = "1.7.1"
|
||||
kotlin = "2.2.21"
|
||||
kotlin = "2.3.0"
|
||||
adaptiveAndroid = "1.2.0"
|
||||
kotlinStdlib = "2.2.21"
|
||||
biometric = "1.4.0-alpha04"
|
||||
kotlinStdlib = "2.3.0"
|
||||
biometric = "1.4.0-alpha05"
|
||||
gson = "2.13.2"
|
||||
kotlinxIoBytestring = "0.8.2"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
kotlinxIoCore = "0.8.2"
|
||||
kotlinxSerializationJson = "1.9.0"
|
||||
material = "1.13.0"
|
||||
activityKtx = "1.12.1"
|
||||
activityKtx = "1.12.2"
|
||||
navigationCompose = "2.9.1"
|
||||
datastore = "1.2.0"
|
||||
ktor = "3.3.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#Sat Sep 06 13:57:07 MSK 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
||||
@@ -14,6 +16,9 @@ kotlin {
|
||||
namespace = "com.pr0gramm3r101.utils"
|
||||
compileSdk = 36
|
||||
minSdk = 24
|
||||
|
||||
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
|
||||
androidResources.enable = true
|
||||
}
|
||||
|
||||
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 {
|
||||
val url = getServerUrl() ?: "fromchat.ru"
|
||||
val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage")
|
||||
val https = getHttpsEnabled() ?: true
|
||||
return ServerConfigData(url, https)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user