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.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>()
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user