Fix login, improve public chat

This commit is contained in:
2025-11-19 19:12:05 +03:00
Unverified
parent a8a0986223
commit 8064a69a04
15 changed files with 998 additions and 204 deletions
+1
View File
@@ -20,3 +20,4 @@ local.properties
keys keys
releases releases
release
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+1
View File
@@ -87,6 +87,7 @@ dependencies {
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json) implementation(libs.ktor.serialization.json)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.haze)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
+2 -2
View File
@@ -3,8 +3,8 @@ package ru.fromchat
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char import kotlinx.datetime.format.char
const val API_HOST = "http://10.0.2.2:8301" const val API_HOST = "https://fromchat.ru"
const val WS_API_HOST = "ws://10.0.2.2:8301" const val WS_API_HOST = "wss://fromchat.ru"
val DATETIME_FORMAT = LocalDateTime.Format { val DATETIME_FORMAT = LocalDateTime.Format {
day() day()
@@ -9,8 +9,10 @@ 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.bearerAuth
import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
@@ -90,4 +92,22 @@ object ApiClient {
} }
.failOnError() .failOnError()
.body<SendMessageResponse>() .body<SendMessageResponse>()
suspend fun editMessage(messageId: Int, content: String) =
http
.put("$API_HOST/api/edit_message/$messageId") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(EditMessageRequest(content))
}
.failOnError()
.body<SendMessageResponse>()
suspend fun deleteMessage(messageId: Int) =
http
.delete("$API_HOST/api/delete_message/$messageId") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
}
.failOnError()
} }
+10 -2
View File
@@ -12,6 +12,7 @@ data class LoginRequest(
@Serializable @Serializable
data class RegisterRequest( data class RegisterRequest(
val username: String, val username: String,
val display_name: String,
val password: String, val password: String,
val confirm_password: String val confirm_password: String
) )
@@ -48,13 +49,15 @@ data class MessagesResponse(
@Serializable @Serializable
data class Message( data class Message(
val id: Int, val id: Int,
val user_id: Int,
val content: String, val content: String,
val timestamp: String, val timestamp: String,
val is_read: Boolean, val is_read: Boolean,
val is_edited: Boolean, val is_edited: Boolean,
val username: String, val username: String,
val profile_picture: String?, val profile_picture: String? = null,
val reply_to: Message? val verified: Boolean? = null,
val reply_to: Message? = null
) { ) {
val utcTimestamp = "${timestamp}Z" val utcTimestamp = "${timestamp}Z"
} }
@@ -65,6 +68,11 @@ data class SendMessageRequest(
val reply_to_id: Int? = null val reply_to_id: Int? = null
) )
@Serializable
data class EditMessageRequest(
val content: String
)
@Serializable @Serializable
data class SendMessageResponse( data class SendMessageResponse(
val status: String, val status: String,
+47 -7
View File
@@ -1,16 +1,21 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
@Composable @Composable
fun RowHeader( fun RowHeader(
@@ -18,23 +23,58 @@ fun RowHeader(
title: String, title: String,
subtitle: String subtitle: String
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Icon( androidx.compose.material3.Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(bottom = 8.dp) .padding(bottom = 8.dp)
.size(30.dp), .size(30.dp),
) )
Text( androidx.compose.material3.Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
Text( androidx.compose.material3.Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
} }
} }
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
hazeState: HazeState,
shape: RoundedCornerShape = RoundedCornerShape(16.dp),
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f),
borderColor: Color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
borderWidth: androidx.compose.ui.unit.Dp = 1.dp,
content: @Composable () -> Unit
) {
androidx.compose.foundation.layout.Box(
modifier = modifier
.clip(shape)
.hazeChild(state = hazeState)
.background(backgroundColor)
.border(borderWidth, borderColor, shape)
.padding(16.dp)
) {
content()
}
}
@Composable
fun GlassContainer(
modifier: Modifier = Modifier,
hazeState: HazeState,
content: @Composable () -> Unit
) {
androidx.compose.foundation.layout.Box(
modifier = modifier
) {
content()
}
}
@@ -5,6 +5,8 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -31,8 +33,14 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
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
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -42,11 +50,13 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -62,6 +72,12 @@ import androidx.compose.ui.text.font.FontWeight
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 androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -69,6 +85,7 @@ import kotlinx.datetime.format
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonObject
import ru.fromchat.DATETIME_FORMAT import ru.fromchat.DATETIME_FORMAT
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
@@ -88,18 +105,62 @@ fun PublicChatScreen() {
val navController = LocalNavController.current val navController = LocalNavController.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val hazeState = remember { HazeState() }
val messages = remember { mutableStateListOf<Message>() } val messages = remember { mutableStateListOf<Message>() }
val typingUsers = remember { mutableStateMapOf<Int, String>() }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var selectedMessageId by remember { mutableStateOf<Int?>(null) }
var showEditDialog by remember { mutableStateOf(false) }
var editingMessage by remember { mutableStateOf<Message?>(null) }
var showDeleteDialog by remember { mutableStateOf(false) }
var deletingMessageId by remember { mutableStateOf<Int?>(null) }
var replyingToMessage by remember { mutableStateOf<Message?>(null) }
var menuMessageId by remember { mutableStateOf<Int?>(null) }
var menuOffset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var typingJob: Job? = null
suspend fun scrollDown(animated: Boolean = true) { suspend fun scrollDown(animated: Boolean = true) {
Log.d("PublicChatScreen", "Scrolling down") if (messages.isNotEmpty()) {
if (animated) { if (animated) {
listState.animateScrollToItem(messages.lastIndex) listState.animateScrollToItem(messages.lastIndex)
} else { } else {
listState.scrollToItem(messages.lastIndex) listState.scrollToItem(messages.lastIndex)
} }
} }
}
fun sendTyping() {
typingJob?.cancel()
typingJob = scope.launch {
try {
WebSocketManager.send(
WebSocketMessage(
type = "typing",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = ApiClient.token!!
),
data = null
)
)
delay(3000)
WebSocketManager.send(
WebSocketMessage(
type = "stopTyping",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = ApiClient.token!!
),
data = null
)
)
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to send typing", e)
}
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
apiRequest { apiRequest {
@@ -112,10 +173,10 @@ fun PublicChatScreen() {
WebSocketManager.connect() WebSocketManager.connect()
WebSocketManager.addGlobalMessageHandler { msg -> WebSocketManager.addGlobalMessageHandler { msg ->
scope.launch { scope.launch {
if (msg.type == "newMessage") { when (msg.type) {
"newMessage" -> {
try { try {
val newMessage = ApiClient.json.decodeFromJsonElement<Message>(msg.data!!) val newMessage = ApiClient.json.decodeFromJsonElement<Message>(msg.data!!)
// Check if message already exists to prevent duplicates
if (messages.none { it.id == newMessage.id }) { if (messages.none { it.id == newMessage.id }) {
messages += newMessage messages += newMessage
scrollDown() scrollDown()
@@ -124,6 +185,52 @@ fun PublicChatScreen() {
Log.e("PublicChatScreen", "Failed to process incoming message:", e) Log.e("PublicChatScreen", "Failed to process incoming message:", e)
} }
} }
"messageEdited" -> {
try {
val editedMessage = ApiClient.json.decodeFromJsonElement<Message>(msg.data!!)
val index = messages.indexOfFirst { it.id == editedMessage.id }
if (index >= 0) {
messages[index] = editedMessage
}
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to process edited message:", e)
}
}
"messageDeleted" -> {
try {
val data = msg.data!!.jsonObject
val messageId = data["message_id"]?.toString()?.toIntOrNull()
if (messageId != null) {
messages.removeAll { it.id == messageId }
}
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to process deleted message:", e)
}
}
"typing" -> {
try {
val data = msg.data!!.jsonObject
val userId = data["userId"]?.toString()?.toIntOrNull()
val username = data["username"]?.toString()
if (userId != null && username != null && userId != ApiClient.user?.id) {
typingUsers[userId] = username
}
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to process typing:", e)
}
}
"stopTyping" -> {
try {
val data = msg.data!!.jsonObject
val userId = data["userId"]?.toString()?.toIntOrNull()
if (userId != null) {
typingUsers.remove(userId)
}
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to process stop typing:", e)
}
}
}
} }
} }
@@ -157,17 +264,70 @@ fun PublicChatScreen() {
) )
}, },
bottomBar = { bottomBar = {
Row( Column(
Modifier modifier = Modifier
.hazeChild(state = hazeState)
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer) .background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f))
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
.fillMaxWidth() .fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top)) .windowInsetsPadding(WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top))
.padding(vertical = 8.dp, horizontal = 12.dp), .padding(vertical = 8.dp, horizontal = 12.dp)
) {
if (replyingToMessage != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Replying to ${replyingToMessage!!.username}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = replyingToMessage!!.content.take(50) + if (replyingToMessage!!.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(
onClick = { replyingToMessage = null },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Cancel reply",
modifier = Modifier.size(16.dp)
)
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val message = rememberTextFieldState() val message = rememberTextFieldState()
LaunchedEffect(message.text) {
if (message.text.isNotEmpty()) {
sendTyping()
}
}
BasicTextField( BasicTextField(
state = message, state = message,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -190,7 +350,7 @@ fun PublicChatScreen() {
IconButton( IconButton(
onClick = { onClick = {
scope.launch { scope.launch {
val messageText = "${message.text.trim()}" val messageText = message.text.trim()
if (messageText.isEmpty()) return@launch if (messageText.isEmpty()) return@launch
try { try {
@@ -203,7 +363,8 @@ fun PublicChatScreen() {
), ),
data = ApiClient.json.encodeToJsonElement( data = ApiClient.json.encodeToJsonElement(
SendMessageRequest( SendMessageRequest(
content = messageText content = messageText.toString(),
reply_to_id = replyingToMessage?.id
) )
) )
) )
@@ -211,14 +372,23 @@ fun PublicChatScreen() {
if (response != null && response.error == null) { if (response != null && response.error == null) {
message.setTextAndPlaceCursorAtEnd("") message.setTextAndPlaceCursorAtEnd("")
Log.d("PublicChatScreen", "Message sent successfully") replyingToMessage = null
// Message will be added to the list via WebSocket "newMessage" event typingJob?.cancel()
WebSocketManager.send(
WebSocketMessage(
type = "stopTyping",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = ApiClient.token!!
),
data = null
)
)
} else { } else {
Log.e("PublicChatScreen", "WebSocket error: ${response?.error}") Log.e("PublicChatScreen", "WebSocket error: ${response?.error}")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to send message", e) Log.e("PublicChatScreen", "Failed to send message", e)
// Could show a toast or error message to user here
} }
} }
} }
@@ -230,98 +400,75 @@ fun PublicChatScreen() {
} }
} }
} }
}
) { innerPadding -> ) { innerPadding ->
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
@Composable items(messages, { it.id }) { message ->
fun Message( MessageBubble(
text: String, message = message,
isAuthor: Boolean, isAuthor = message.user_id == ApiClient.user!!.id,
isRead: Boolean, hazeState = hazeState,
timestamp: String, onTap = { offset ->
username: String menuMessageId = message.id
) { menuOffset = offset
var rowWidth by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
.fillMaxWidth()
.onSizeChanged {
rowWidth = with(density) {
it.width.toDp()
}
}, },
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start onEdit = {
) { editingMessage = message
val background = showEditDialog = true
if (isAuthor) menuMessageId = null
MaterialTheme.colorScheme.primaryContainer },
else MaterialTheme.colorScheme.surfaceContainer onDelete = {
deletingMessageId = message.id
showDeleteDialog = true
menuMessageId = null
},
onReply = {
replyingToMessage = message
menuMessageId = null
}
)
}
Column( if (typingUsers.isNotEmpty()) {
Modifier item {
.clip( TypingIndicator(
RoundedCornerShape( users = typingUsers.values.toList(),
topStart = if (isAuthor) 16.dp else 5.dp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
topEnd = 16.dp,
bottomEnd = if (isAuthor) 5.dp else 16.dp,
bottomStart = 16.dp
) )
)
.background(background)
.padding(10.dp)
.width(IntrinsicSize.Max)
.widthIn(max = rowWidth * 0.80f)
) {
if (!isAuthor) {
Text(
text = username,
fontWeight = FontWeight.W600,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(text)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp, Alignment.End),
modifier = Modifier.fillMaxWidth()
) {
Spacer(Modifier.weight(1f))
Text(
text = timestamp,
fontSize = 14.sp
)
if (isRead) {
Icon(
imageVector = Icons.Filled.DoneAll,
contentDescription = "Read",
modifier = Modifier.size(17.dp)
)
}
}
} }
} }
} }
items(messages, { it.id }) { if (menuMessageId != null) {
Message( val message = messages.find { it.id == menuMessageId }
text = it.content, if (message != null) {
isAuthor = it.username == ApiClient.user!!.username, MessageMenu(
isRead = it.is_read, message = message,
timestamp = Instant offset = menuOffset,
.parse(it.utcTimestamp) onDismiss = { menuMessageId = null },
.toLocalDateTime(TimeZone.currentSystemDefault()) onEdit = {
.format(DATETIME_FORMAT), editingMessage = message
username = it.username showEditDialog = true
menuMessageId = null
},
onDelete = {
deletingMessageId = message.id
showDeleteDialog = true
menuMessageId = null
},
onReply = {
replyingToMessage = message
menuMessageId = null
},
hazeState = hazeState
) )
} }
} }
}
AnimatedVisibility( AnimatedVisibility(
visible = loading, visible = loading,
@@ -338,5 +485,337 @@ fun PublicChatScreen() {
} }
} }
} }
if (showEditDialog && editingMessage != null) {
EditMessageDialog(
message = editingMessage!!,
onDismiss = {
showEditDialog = false
editingMessage = null
},
onConfirm = { newContent ->
scope.launch {
try {
apiRequest {
ApiClient.editMessage(editingMessage!!.id, newContent)
}
showEditDialog = false
editingMessage = null
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to edit message", e)
}
}
}
)
}
if (showDeleteDialog && deletingMessageId != null) {
DeleteMessageDialog(
onDismiss = {
showDeleteDialog = false
deletingMessageId = null
},
onConfirm = {
scope.launch {
try {
apiRequest {
ApiClient.deleteMessage(deletingMessageId!!)
}
showDeleteDialog = false
deletingMessageId = null
} catch (e: Exception) {
Log.e("PublicChatScreen", "Failed to delete message", e)
}
}
}
)
} }
} }
@OptIn(ExperimentalTime::class)
@Composable
fun MessageBubble(
message: Message,
isAuthor: Boolean,
hazeState: HazeState,
onTap: (androidx.compose.ui.geometry.Offset) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onReply: () -> Unit
) {
var rowWidth by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
.fillMaxWidth()
.onSizeChanged {
rowWidth = with(density) {
it.width.toDp()
}
},
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start
) {
val background = if (isAuthor)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
else
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f)
Box(
modifier = Modifier
.clip(
RoundedCornerShape(
topStart = if (isAuthor) 16.dp else 5.dp,
topEnd = 16.dp,
bottomEnd = if (isAuthor) 5.dp else 16.dp,
bottomStart = 16.dp
)
)
.hazeChild(state = hazeState)
.background(background)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(
topStart = if (isAuthor) 16.dp else 5.dp,
topEnd = 16.dp,
bottomEnd = if (isAuthor) 5.dp else 16.dp,
bottomStart = 16.dp
))
.clickable {
onTap(androidx.compose.ui.geometry.Offset(0f, 0f))
}
.width(IntrinsicSize.Max)
.widthIn(max = rowWidth * 0.80f)
) {
Column(
modifier = Modifier.padding(10.dp)
) {
if (message.reply_to != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(8.dp)
) {
Text(
text = message.reply_to.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.W600,
color = MaterialTheme.colorScheme.primary
)
Text(
text = message.reply_to.content,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
if (!isAuthor) {
Text(
text = message.username,
fontWeight = FontWeight.W600,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(message.content)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
Spacer(Modifier.weight(1f))
if (message.is_edited) {
Text(
text = "(edited)",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = Instant
.parse(message.utcTimestamp)
.toLocalDateTime(TimeZone.currentSystemDefault())
.format(DATETIME_FORMAT),
fontSize = 12.sp
)
if (isAuthor && message.is_read) {
Icon(
imageVector = Icons.Filled.DoneAll,
contentDescription = "Read",
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
@Composable
fun TypingIndicator(
users: List<String>,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.7f)
)
) {
Text(
text = if (users.size == 1) {
"${users[0]} is typing..."
} else {
"${users.size} people are typing..."
},
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun MessageMenu(
message: Message,
offset: androidx.compose.ui.geometry.Offset,
onDismiss: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onReply: () -> Unit,
hazeState: HazeState
) {
val isAuthor = message.user_id == ApiClient.user!!.id
Popup(
onDismissRequest = onDismiss,
offset = androidx.compose.ui.unit.IntOffset(offset.x.toInt(), offset.y.toInt()),
properties = PopupProperties(focusable = true)
) {
GlassSurface(
hazeState = hazeState,
shape = RoundedCornerShape(12.dp)
) {
Column {
MenuItem(
icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply",
onClick = {
onReply()
onDismiss()
}
)
if (isAuthor) {
MenuItem(
icon = Icons.Default.Edit,
text = "Edit",
onClick = {
onEdit()
onDismiss()
}
)
MenuItem(
icon = Icons.Default.Delete,
text = "Delete",
onClick = {
onDelete()
onDismiss()
}
)
}
}
}
}
}
@Composable
fun MenuItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier.size(20.dp)
)
Text(text)
}
}
@Composable
fun EditMessageDialog(
message: Message,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var text by remember { mutableStateOf(message.content) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit Message") },
text = {
BasicTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
textStyle = LocalTextStyle.current,
singleLine = false
)
},
confirmButton = {
TextButton(
onClick = {
if (text.trim().isNotEmpty()) {
onConfirm(text.trim())
}
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
fun DeleteMessageDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete Message") },
text = { Text("Are you sure you want to delete this message?") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
+31 -31
View File
@@ -12,45 +12,45 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF91CEF4), primary = Color(0xFFDBB9F9),
onPrimary = Color(0xFF00344A), onPrimary = Color(0xFF3E2458),
primaryContainer = Color(0xFF004C6A), primaryContainer = Color(0xFF563B71),
onPrimaryContainer = Color(0xFFC5E7FF), onPrimaryContainer = Color(0xFFF0DBFF),
secondary = Color(0xFFB6C9D8), secondary = Color(0xFFD0C1DA),
onSecondary = Color(0xFF20333E), onSecondary = Color(0xFF362C3F),
secondaryContainer = Color(0xFF374955), secondaryContainer = Color(0xFF4D4356),
onSecondaryContainer = Color(0xFFD2E5F4), onSecondaryContainer = Color(0xFFEDDDF6),
tertiary = Color(0xFFCBC1E9), tertiary = Color(0xFFF3B7BE),
onTertiary = Color(0xFF332C4C), onTertiary = Color(0xFF4B252B),
tertiaryContainer = Color(0xFF494263), tertiaryContainer = Color(0xFF653A40),
onTertiaryContainer = Color(0xFFE7DEFF), onTertiaryContainer = Color(0xFFFFD9DD),
error = Color(0xFFFFB4AB), error = Color(0xFFFFB4AB),
onError = Color(0xFF690005), onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A), errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF0F1417), background = Color(0xFF151218),
onBackground = Color(0xFFDFE3E7), onBackground = Color(0xFFE8E0E8),
surface = Color(0xFF0F1417), surface = Color(0xFF151218),
onSurface = Color(0xFFDFE3E7), onSurface = Color(0xFFE8E0E8),
surfaceVariant = Color(0xFF41484D), surfaceVariant = Color(0xFF4A454E),
onSurfaceVariant = Color(0xFFC1C7CE), onSurfaceVariant = Color(0xFFCCC4CE),
outline = Color(0xFF8B9297), outline = Color(0xFF968E98),
outlineVariant = Color(0xFF41484D), outlineVariant = Color(0xFF4A454E),
scrim = Color(0xFF000000), scrim = Color(0xFF000000),
inverseSurface = Color(0xFFDFE3E7), inverseSurface = Color(0xFFE8E0E8),
inverseOnSurface = Color(0xFF2C3134), inverseOnSurface = Color(0xFF332F35),
inversePrimary = Color(0xFF1E6586), inversePrimary = Color(0xFF6F528A),
surfaceDim = Color(0xFF0F1417), surfaceDim = Color(0xFF151218),
surfaceBright = Color(0xFF353A3D), surfaceBright = Color(0xFF3C383E),
surfaceContainerLowest = Color(0xFF0A0F12), surfaceContainerLowest = Color(0xFF100D12),
surfaceContainerLow = Color(0xFF181C1F), surfaceContainerLow = Color(0xFF1E1A20),
surfaceContainer = Color(0xFF1C2023), surfaceContainer = Color(0xFF221E24),
surfaceContainerHigh = Color(0xFF262B2E), surfaceContainerHigh = Color(0xFF2C292E),
surfaceContainerHighest = Color(0xFF313539), surfaceContainerHighest = Color(0xFF373339),
) )
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1E6586), primary = Color(0xFF1F6586),
onPrimary = Color(0xFFFFFFFF), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFC5E7FF), primaryContainer = Color(0xFFC5E7FF),
onPrimaryContainer = Color(0xFF004C6A), onPrimaryContainer = Color(0xFF004C6A),
@@ -33,6 +33,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest import ru.fromchat.api.apiRequest
import ru.fromchat.ui.RowHeader import ru.fromchat.ui.RowHeader
import ru.fromchat.utils.HashUtils
@Composable @Composable
fun LoginScreen( fun LoginScreen(
@@ -95,14 +96,17 @@ fun LoginScreen(
return@Button return@Button
} }
// Derive auth secret before sending (matches frontend implementation)
scope.launch { scope.launch {
val derived = HashUtils.deriveAuthSecret(username.trim(), password.trim())
apiRequest( apiRequest(
onError = { message, _ -> onError = { message, _ ->
alert = message alert = message
}, },
onSuccess = { onLoginSuccess() } onSuccess = { onLoginSuccess() }
) { ) {
ApiClient.login(LoginRequest(username.trim(), password.trim())) ApiClient.login(LoginRequest(username.trim(), derived))
} }
} }
} }
@@ -37,6 +37,7 @@ import ru.fromchat.api.RegisterRequest
import ru.fromchat.api.apiRequest import ru.fromchat.api.apiRequest
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.RowHeader import ru.fromchat.ui.RowHeader
import ru.fromchat.utils.HashUtils
@Composable @Composable
fun RegisterScreen( fun RegisterScreen(
@@ -44,6 +45,7 @@ fun RegisterScreen(
) { ) {
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding -> Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") }
var alert by remember { mutableStateOf<String?>(null) } var alert by remember { mutableStateOf<String?>(null) }
@@ -82,6 +84,13 @@ fun RegisterScreen(
singleLine = true singleLine = true
) )
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display Name") },
singleLine = true
)
OutlinedTextField( OutlinedTextField(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
@@ -116,7 +125,7 @@ fun RegisterScreen(
Button( Button(
onClick = { onClick = {
// Checks // Checks
if (username.isBlank() || password.isBlank() || confirmPassword.isBlank()) { if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
alert = alert_error_filling alert = alert_error_filling
return@Button return@Button
} }
@@ -128,13 +137,19 @@ fun RegisterScreen(
alert = alert_error_name_little alert = alert_error_name_little
return@Button return@Button
} }
if (displayName.isBlank() || displayName.length > 64) {
alert = "Display name must be between 1 and 64 characters"
return@Button
}
if (password.length !in 5..50) { if (password.length !in 5..50) {
alert = alert_error_password_little alert = alert_error_password_little
return@Button return@Button
} }
// Send the register request // Derive auth secret before sending (matches frontend implementation)
scope.launch { scope.launch {
val derived = HashUtils.deriveAuthSecret(username.trim(), password)
apiRequest( apiRequest(
onError = { message, _ -> onError = { message, _ ->
alert = message alert = message
@@ -143,9 +158,10 @@ fun RegisterScreen(
) { ) {
ApiClient.register( ApiClient.register(
RegisterRequest( RegisterRequest(
username, username.trim(),
password, displayName.trim(),
confirmPassword derived,
derived
) )
) )
} }
@@ -0,0 +1,69 @@
package ru.fromchat.utils
import android.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object HashUtils {
/**
* Derive authentication secret using HKDF (HMAC-based Key Derivation Function)
* Matches the frontend implementation in deriveAuthSecret
*
* @param username The username
* @param password The plain password
* @return Base64-encoded 32-byte derived secret
*/
fun deriveAuthSecret(username: String, password: String): String {
val salt = "fromchat.user:$username".toByteArray(Charsets.UTF_8)
val ikm = password.toByteArray(Charsets.UTF_8)
val info = "auth-secret".toByteArray(Charsets.UTF_8)
val length = 32
val prk = hkdfExtract(salt, ikm)
val okm = hkdfExpand(prk, info, length)
return Base64.encodeToString(okm, Base64.NO_WRAP)
}
/**
* HKDF Extract step: PRK = HMAC-Hash(salt, IKM)
*/
private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(salt, "HmacSHA256")
mac.init(keySpec)
return mac.doFinal(ikm)
}
/**
* HKDF Expand step: OKM = HKDF-Expand(PRK, info, L)
*/
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
val blocks = mutableListOf<ByteArray>()
var previous = ByteArray(0)
var counter = 1
while (blocks.sumOf { it.size } < length) {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(prk, "HmacSHA256")
mac.init(keySpec)
val input = previous + info + byteArrayOf(counter.toByte())
previous = mac.doFinal(input)
blocks.add(previous)
counter++
}
val result = ByteArray(length)
var offset = 0
for (block in blocks) {
val toCopy = minOf(block.size, length - offset)
System.arraycopy(block, 0, result, offset, toCopy)
offset += toCopy
if (offset >= length) break
}
return result
}
}
+2
View File
@@ -15,6 +15,7 @@ ktor = "3.3.0"
kotlinxSerializationJson = "1.9.0" kotlinxSerializationJson = "1.9.0"
compose = "1.9.2" compose = "1.9.2"
material3 = "1.5.0-alpha04" material3 = "1.5.0-alpha04"
haze = "0.7.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -41,6 +42,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }