Working public chat

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2025-09-09 18:52:49 +03:00
Unverified
parent b7021bb469
commit 6d85dc767f
5 changed files with 159 additions and 29 deletions
@@ -7,6 +7,8 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger 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.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
@@ -44,6 +46,10 @@ object ApiClient {
var token: String? = null var token: String? = null
private set private set
@Volatile
var user: User? = null
private set
suspend fun login(request: LoginRequest) = suspend fun login(request: LoginRequest) =
http http
.post("https://fromchat.ru/api/login") { .post("https://fromchat.ru/api/login") {
@@ -52,7 +58,10 @@ object ApiClient {
} }
.failOnError() .failOnError()
.body<LoginResponse>() .body<LoginResponse>()
.also { token = it.token } .also {
token = it.token
user = it.user
}
suspend fun register(request: RegisterRequest) = suspend fun register(request: RegisterRequest) =
http http
@@ -61,4 +70,22 @@ object ApiClient {
setBody(request) setBody(request)
} }
.failOnError() .failOnError()
suspend fun getMessages() =
http
.get("https://fromchat.ru/api/get_messages") {
contentType(ContentType.Application.Json)
}
.failOnError()
.body<MessagesResponse>()
suspend fun send(message: String) =
http
.post("https://fromchat.ru/api/send_message") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(SendMessageRequest(message))
}
.failOnError()
.body<SendMessageResponse>()
} }
+31 -1
View File
@@ -1,6 +1,7 @@
package ru.fromchat.api package ru.fromchat.api
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable @Serializable
data class LoginRequest( data class LoginRequest(
@@ -38,6 +39,35 @@ data class LoginResponse(
val token: String val token: String
) )
@Serializable
data class MessagesResponse(
val status: String,
val messages: List<Message>
)
@Serializable
data class Message(
val id: Int,
val content: String,
val timestamp: String,
val is_read: Boolean,
val is_edited: Boolean,
val username: String,
val profile_picture: String?,
val reply_to: Message?
)
@Serializable
data class SendMessageRequest(
val content: String
)
@Serializable
data class SendMessageResponse(
val status: String,
val message: Message
)
// WebSocket types mirror frontend src/core/types.d.ts // WebSocket types mirror frontend src/core/types.d.ts
@Serializable @Serializable
data class WebSocketCredentials( data class WebSocketCredentials(
@@ -55,6 +85,6 @@ data class WebSocketError(
data class WebSocketMessage( data class WebSocketMessage(
val type: String, val type: String,
val credentials: WebSocketCredentials? = null, val credentials: WebSocketCredentials? = null,
val data: kotlinx.serialization.json.JsonElement? = null, val data: JsonElement? = null,
val error: WebSocketError? = null val error: WebSocketError? = null
) )
+13 -10
View File
@@ -1,11 +1,12 @@
package ru.fromchat.api package ru.fromchat.api
import android.util.Log
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
suspend inline fun <Response> apiRequest( suspend inline fun <Response> apiRequest(
onError: (String, Exception) -> Unit, onError: (String, Exception) -> Unit = { _, _ -> },
onSuccess: (Response) -> Unit, onSuccess: (Response) -> Unit = {},
request: suspend () -> Response request: suspend () -> Response
): Result<Response> { ): Result<Response> {
try { try {
@@ -13,16 +14,18 @@ suspend inline fun <Response> apiRequest(
onSuccess(response) onSuccess(response)
return Result.success(response) return Result.success(response)
} catch (e: ClientRequestException) { } catch (e: ClientRequestException) {
onError( val message = if (e.response.status.value in arrayOf(401, 403)) {
if (e.response.status.value in arrayOf(401, 403)) { e.response.body<ErrorResponse>().detail
e.response.body<ErrorResponse>().detail } else {
} else { "Unexpected error"
"Unexpected error" }
},
e Log.e("API", "API request failed: $message, exception:", e)
)
onError(message, e)
return Result.failure(e) return Result.failure(e)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("API", "API request failed:", e)
onError("Unexpected error", e) onError("Unexpected error", e)
return Result.failure(e) return Result.failure(e)
} }
@@ -1,5 +1,6 @@
package ru.fromchat.api package ru.fromchat.api
import android.util.Log
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
@@ -42,6 +43,7 @@ object WebSocketManager {
@Volatile private var session: DefaultClientWebSocketSession? = null @Volatile private var session: DefaultClientWebSocketSession? = null
fun connect() { fun connect() {
Log.d("WebSocketManager", "Connecting to WebSocket")
if (connecting) return if (connecting) return
connecting = true connecting = true
@@ -61,20 +63,25 @@ object WebSocketManager {
) { ) {
session = this session = this
Log.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
Log.d("WebSocketManager", "Received payload: $text")
try { try {
val msg = json.decodeFromString(WebSocketMessage.serializer(), text) val msg = json.decodeFromString<WebSocketMessage>(text)
globalHandler?.invoke(msg) globalHandler?.invoke(msg)
_messages.emit(msg) _messages.emit(msg)
} catch (_: Throwable) { } catch (e: Throwable) {
Log.w("WebSocketManager", "Received malformed payload:", e)
// ignore malformed // ignore malformed
} }
} }
} }
} catch (_: Throwable) { } catch (e: Throwable) {
Log.w("WebSocketManager", "An error occurred:", e)
delay(3000) delay(3000)
} finally { } finally {
Log.w("WebSocketManager", "WebSocket disconnected")
session = null session = null
} }
} }
@@ -1,7 +1,9 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -21,6 +23,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -35,6 +38,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -42,12 +49,51 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.apiRequest
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PublicChatScreen() { fun PublicChatScreen() {
val navController = LocalNavController.current val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val messages = remember { mutableStateListOf<Message>() }
LaunchedEffect(Unit) {
val response = apiRequest {
ApiClient.getMessages()
}
if (response.isSuccess) {
messages.clear()
messages += response.getOrThrow().messages
}
WebSocketManager.connect()
WebSocketManager.setGlobalMessageHandler { msg ->
scope.launch {
try {
messages += Json.decodeFromJsonElement<Message>(msg.data!!)
delay(500)
scrollState.animateScrollTo(scrollState.maxValue)
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to process incoming message:", e)
}
}
}
delay(500)
scrollState.animateScrollTo(scrollState.maxValue)
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -93,12 +139,35 @@ fun PublicChatScreen() {
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4) lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
decorator = { innerTextField ->
Box {
if (message.text.isEmpty()) {
Text(
text = "Write a message...",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
}
) )
IconButton( IconButton(
onClick = { onClick = {
// send scope.launch {
val response = apiRequest {
ApiClient.send(message.text.toString())
}
message.setTextAndPlaceCursorAtEnd("")
if (response.isSuccess) {
messages += response.getOrThrow().message
delay(500)
scrollState.animateScrollTo(scrollState.maxValue)
}
}
} }
) { ) {
Icon( Icon(
@@ -112,7 +181,7 @@ fun PublicChatScreen() {
Column( Column(
Modifier Modifier
.padding(innerPadding) .padding(innerPadding)
.verticalScroll(rememberScrollState()) .verticalScroll(scrollState)
) { ) {
@Composable @Composable
fun Message( fun Message(
@@ -169,18 +238,12 @@ fun PublicChatScreen() {
} }
} }
repeat(100) { messages.forEach {
Message( Message(
text = "test", text = it.content,
isAuthor = true, isAuthor = it.username == ApiClient.user!!.username,
isRead = true, isRead = it.is_read,
timestamp = "9:41" timestamp = it.timestamp
)
Message(
text = "test2",
isAuthor = false,
isRead = true,
timestamp = "9:41"
) )
} }
} }