mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
+28
-1
@@ -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>()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user