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.Logging
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.setBody
import io.ktor.http.ContentType
@@ -44,6 +46,10 @@ object ApiClient {
var token: String? = null
private set
@Volatile
var user: User? = null
private set
suspend fun login(request: LoginRequest) =
http
.post("https://fromchat.ru/api/login") {
@@ -52,7 +58,10 @@ object ApiClient {
}
.failOnError()
.body<LoginResponse>()
.also { token = it.token }
.also {
token = it.token
user = it.user
}
suspend fun register(request: RegisterRequest) =
http
@@ -61,4 +70,22 @@ object ApiClient {
setBody(request)
}
.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
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class LoginRequest(
@@ -38,6 +39,35 @@ data class LoginResponse(
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
@Serializable
data class WebSocketCredentials(
@@ -55,6 +85,6 @@ data class WebSocketError(
data class WebSocketMessage(
val type: String,
val credentials: WebSocketCredentials? = null,
val data: kotlinx.serialization.json.JsonElement? = null,
val data: JsonElement? = null,
val error: WebSocketError? = null
)
+13 -10
View File
@@ -1,11 +1,12 @@
package ru.fromchat.api
import android.util.Log
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
suspend inline fun <Response> apiRequest(
onError: (String, Exception) -> Unit,
onSuccess: (Response) -> Unit,
onError: (String, Exception) -> Unit = { _, _ -> },
onSuccess: (Response) -> Unit = {},
request: suspend () -> Response
): Result<Response> {
try {
@@ -13,16 +14,18 @@ suspend inline fun <Response> apiRequest(
onSuccess(response)
return Result.success(response)
} catch (e: ClientRequestException) {
onError(
if (e.response.status.value in arrayOf(401, 403)) {
e.response.body<ErrorResponse>().detail
} else {
"Unexpected error"
},
e
)
val message = if (e.response.status.value in arrayOf(401, 403)) {
e.response.body<ErrorResponse>().detail
} else {
"Unexpected error"
}
Log.e("API", "API request failed: $message, exception:", e)
onError(message, e)
return Result.failure(e)
} catch (e: Exception) {
Log.e("API", "API request failed:", e)
onError("Unexpected error", e)
return Result.failure(e)
}
@@ -1,5 +1,6 @@
package ru.fromchat.api
import android.util.Log
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.HttpMethod
@@ -42,6 +43,7 @@ object WebSocketManager {
@Volatile private var session: DefaultClientWebSocketSession? = null
fun connect() {
Log.d("WebSocketManager", "Connecting to WebSocket")
if (connecting) return
connecting = true
@@ -61,20 +63,25 @@ object WebSocketManager {
) {
session = this
Log.d("WebSocketManager", "WebSocket connected")
for (frame in incoming) {
val text = (frame as? Frame.Text)?.readText() ?: continue
Log.d("WebSocketManager", "Received payload: $text")
try {
val msg = json.decodeFromString(WebSocketMessage.serializer(), text)
val msg = json.decodeFromString<WebSocketMessage>(text)
globalHandler?.invoke(msg)
_messages.emit(msg)
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.w("WebSocketManager", "Received malformed payload:", e)
// ignore malformed
}
}
}
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.w("WebSocketManager", "An error occurred:", e)
delay(3000)
} finally {
Log.w("WebSocketManager", "WebSocket disconnected")
session = null
}
}
@@ -1,7 +1,9 @@
package ru.fromchat.ui.chat
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
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.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.TopAppBar
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.Modifier
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.unit.dp
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PublicChatScreen() {
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(
topBar = {
@@ -93,12 +139,35 @@ fun PublicChatScreen() {
modifier = Modifier.weight(1f),
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
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(
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(
@@ -112,7 +181,7 @@ fun PublicChatScreen() {
Column(
Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
) {
@Composable
fun Message(
@@ -169,18 +238,12 @@ fun PublicChatScreen() {
}
}
repeat(100) {
messages.forEach {
Message(
text = "test",
isAuthor = true,
isRead = true,
timestamp = "9:41"
)
Message(
text = "test2",
isAuthor = false,
isRead = true,
timestamp = "9:41"
text = it.content,
isAuthor = it.username == ApiClient.user!!.username,
isRead = it.is_read,
timestamp = it.timestamp
)
}
}