From 8064a69a04a24ecb3e6c4d59c1f7667187a9e191 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 19 Nov 2025 19:12:05 +0300 Subject: [PATCH] Fix login, improve public chat --- .gitignore | 3 +- .idea/appInsightsSettings.xml | 26 + .idea/codeStyles/Project.xml | 123 +++ .idea/codeStyles/codeStyleConfig.xml | 5 + app/build.gradle.kts | 1 + app/src/main/java/ru/fromchat/Constants.kt | 4 +- .../main/java/ru/fromchat/api/ApiClient.kt | 20 + app/src/main/java/ru/fromchat/api/Models.kt | 12 +- .../main/java/ru/fromchat/ui/Components.kt | 56 +- .../java/ru/fromchat/ui/PublicChatScreen.kt | 787 ++++++++++++++---- app/src/main/java/ru/fromchat/ui/Theme.kt | 62 +- .../java/ru/fromchat/ui/auth/LoginScreen.kt | 6 +- .../ru/fromchat/ui/auth/RegisterScreen.kt | 26 +- .../main/java/ru/fromchat/utils/HashUtils.kt | 69 ++ gradle/libs.versions.toml | 2 + 15 files changed, 998 insertions(+), 204 deletions(-) create mode 100644 .idea/appInsightsSettings.xml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 app/src/main/java/ru/fromchat/utils/HashUtils.kt diff --git a/.gitignore b/.gitignore index 9502fb1..74e3a25 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ build local.properties keys -releases \ No newline at end of file +releases +release \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d9514e4..a999a24 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) implementation(libs.kotlinx.serialization.json) + implementation(libs.haze) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/ru/fromchat/Constants.kt b/app/src/main/java/ru/fromchat/Constants.kt index 2e6833a..b5b04a0 100644 --- a/app/src/main/java/ru/fromchat/Constants.kt +++ b/app/src/main/java/ru/fromchat/Constants.kt @@ -3,8 +3,8 @@ package ru.fromchat import kotlinx.datetime.LocalDateTime import kotlinx.datetime.format.char -const val API_HOST = "http://10.0.2.2:8301" -const val WS_API_HOST = "ws://10.0.2.2:8301" +const val API_HOST = "https://fromchat.ru" +const val WS_API_HOST = "wss://fromchat.ru" val DATETIME_FORMAT = LocalDateTime.Format { day() diff --git a/app/src/main/java/ru/fromchat/api/ApiClient.kt b/app/src/main/java/ru/fromchat/api/ApiClient.kt index 6882bf5..0edf560 100644 --- a/app/src/main/java/ru/fromchat/api/ApiClient.kt +++ b/app/src/main/java/ru/fromchat/api/ApiClient.kt @@ -9,8 +9,10 @@ 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.delete import io.ktor.client.request.get import io.ktor.client.request.post +import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType @@ -90,4 +92,22 @@ object ApiClient { } .failOnError() .body() + + 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() + + suspend fun deleteMessage(messageId: Int) = + http + .delete("$API_HOST/api/delete_message/$messageId") { + contentType(ContentType.Application.Json) + bearerAuth(token!!) + } + .failOnError() } \ No newline at end of file diff --git a/app/src/main/java/ru/fromchat/api/Models.kt b/app/src/main/java/ru/fromchat/api/Models.kt index 1dba403..25bb87f 100644 --- a/app/src/main/java/ru/fromchat/api/Models.kt +++ b/app/src/main/java/ru/fromchat/api/Models.kt @@ -12,6 +12,7 @@ data class LoginRequest( @Serializable data class RegisterRequest( val username: String, + val display_name: String, val password: String, val confirm_password: String ) @@ -48,13 +49,15 @@ data class MessagesResponse( @Serializable data class Message( val id: Int, + val user_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? + val profile_picture: String? = null, + val verified: Boolean? = null, + val reply_to: Message? = null ) { val utcTimestamp = "${timestamp}Z" } @@ -65,6 +68,11 @@ data class SendMessageRequest( val reply_to_id: Int? = null ) +@Serializable +data class EditMessageRequest( + val content: String +) + @Serializable data class SendMessageResponse( val status: String, diff --git a/app/src/main/java/ru/fromchat/ui/Components.kt b/app/src/main/java/ru/fromchat/ui/Components.kt index 56af405..d3221d7 100644 --- a/app/src/main/java/ru/fromchat/ui/Components.kt +++ b/app/src/main/java/ru/fromchat/ui/Components.kt @@ -1,16 +1,21 @@ 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.padding 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.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment 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.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.haze +import dev.chrisbanes.haze.hazeChild @Composable fun RowHeader( @@ -18,23 +23,58 @@ fun RowHeader( title: String, subtitle: String ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( + Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { + androidx.compose.material3.Icon( imageVector = icon, contentDescription = null, modifier = Modifier .padding(bottom = 8.dp) .size(30.dp), ) - Text( + androidx.compose.material3.Text( text = title, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 8.dp) ) - Text( + androidx.compose.material3.Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 16.dp) ) } -} \ No newline at end of file +} + +@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() + } +} diff --git a/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt b/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt index 11e3796..9e5db74 100644 --- a/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt +++ b/app/src/main/java/ru/fromchat/ui/PublicChatScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.fadeOut 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.Box 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.material.icons.Icons 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.filled.Delete 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.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -42,11 +50,13 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.unit.dp 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.launch import kotlinx.datetime.TimeZone @@ -69,6 +85,7 @@ import kotlinx.datetime.format import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import ru.fromchat.DATETIME_FORMAT import ru.fromchat.R import ru.fromchat.api.ApiClient @@ -88,16 +105,60 @@ fun PublicChatScreen() { val navController = LocalNavController.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() + val hazeState = remember { HazeState() } val messages = remember { mutableStateListOf() } + val typingUsers = remember { mutableStateMapOf() } var loading by remember { mutableStateOf(true) } + var selectedMessageId by remember { mutableStateOf(null) } + var showEditDialog by remember { mutableStateOf(false) } + var editingMessage by remember { mutableStateOf(null) } + var showDeleteDialog by remember { mutableStateOf(false) } + var deletingMessageId by remember { mutableStateOf(null) } + var replyingToMessage by remember { mutableStateOf(null) } + var menuMessageId by remember { mutableStateOf(null) } + var menuOffset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + + var typingJob: Job? = null suspend fun scrollDown(animated: Boolean = true) { - Log.d("PublicChatScreen", "Scrolling down") - if (animated) { - listState.animateScrollToItem(messages.lastIndex) - } else { - listState.scrollToItem(messages.lastIndex) + if (messages.isNotEmpty()) { + if (animated) { + listState.animateScrollToItem(messages.lastIndex) + } else { + 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) + } } } @@ -112,16 +173,62 @@ fun PublicChatScreen() { WebSocketManager.connect() WebSocketManager.addGlobalMessageHandler { msg -> scope.launch { - if (msg.type == "newMessage") { - try { - val newMessage = ApiClient.json.decodeFromJsonElement(msg.data!!) - // Check if message already exists to prevent duplicates - if (messages.none { it.id == newMessage.id }) { - messages += newMessage - scrollDown() + when (msg.type) { + "newMessage" -> { + try { + val newMessage = ApiClient.json.decodeFromJsonElement(msg.data!!) + if (messages.none { it.id == newMessage.id }) { + messages += newMessage + scrollDown() + } + } catch (e: Exception) { + Log.e("PublicChatScreen", "Failed to process incoming message:", e) + } + } + "messageEdited" -> { + try { + val editedMessage = ApiClient.json.decodeFromJsonElement(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) } - } catch (e: Exception) { - Log.e("PublicChatScreen", "Failed to process incoming message:", e) } } } @@ -157,76 +264,140 @@ fun PublicChatScreen() { ) }, bottomBar = { - Row( - Modifier + Column( + modifier = Modifier + .hazeChild(state = hazeState) .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() .windowInsetsPadding(WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top)) - .padding(vertical = 8.dp, horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 8.dp, horizontal = 12.dp) ) { - val message = rememberTextFieldState() - - BasicTextField( - state = message, - modifier = Modifier.weight(1f), - textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), - decorator = { innerTextField -> - Box { - if (message.text.isEmpty()) { + 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 = stringResource(R.string.message_placeholder), - color = MaterialTheme.colorScheme.onSurfaceVariant + 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) ) } - innerTextField() } } - ) + } - IconButton( - onClick = { - scope.launch { - val messageText = "${message.text.trim()}" - if (messageText.isEmpty()) return@launch - - try { - val response = WebSocketManager.request( - WebSocketMessage( - type = "sendMessage", - credentials = WebSocketCredentials( - scheme = "Bearer", - credentials = ApiClient.token!! - ), - data = ApiClient.json.encodeToJsonElement( - SendMessageRequest( - content = messageText + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val message = rememberTextFieldState() + + LaunchedEffect(message.text) { + if (message.text.isNotEmpty()) { + sendTyping() + } + } + + BasicTextField( + state = message, + modifier = Modifier.weight(1f), + textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), + decorator = { innerTextField -> + Box { + if (message.text.isEmpty()) { + Text( + text = stringResource(R.string.message_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } + } + ) + + IconButton( + onClick = { + scope.launch { + val messageText = message.text.trim() + if (messageText.isEmpty()) return@launch + + try { + val response = WebSocketManager.request( + WebSocketMessage( + type = "sendMessage", + credentials = WebSocketCredentials( + scheme = "Bearer", + credentials = ApiClient.token!! + ), + data = ApiClient.json.encodeToJsonElement( + SendMessageRequest( + content = messageText.toString(), + reply_to_id = replyingToMessage?.id + ) ) ) ) - ) - - if (response != null && response.error == null) { - message.setTextAndPlaceCursorAtEnd("") - Log.d("PublicChatScreen", "Message sent successfully") - // Message will be added to the list via WebSocket "newMessage" event - } else { - Log.e("PublicChatScreen", "WebSocket error: ${response?.error}") + + if (response != null && response.error == null) { + message.setTextAndPlaceCursorAtEnd("") + replyingToMessage = null + typingJob?.cancel() + WebSocketManager.send( + WebSocketMessage( + type = "stopTyping", + credentials = WebSocketCredentials( + scheme = "Bearer", + credentials = ApiClient.token!! + ), + data = null + ) + ) + } else { + Log.e("PublicChatScreen", "WebSocket error: ${response?.error}") + } + } catch (e: Exception) { + Log.e("PublicChatScreen", "Failed to send message", e) } - } catch (e: Exception) { - Log.e("PublicChatScreen", "Failed to send message", e) - // Could show a toast or error message to user here } } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send" + ) } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send" - ) } } } @@ -236,107 +407,415 @@ fun PublicChatScreen() { state = listState, modifier = Modifier.padding(innerPadding) ) { - @Composable - fun Message( - text: String, - isAuthor: Boolean, - isRead: Boolean, - timestamp: String, - username: String - ) { - var rowWidth by remember { mutableStateOf(0.dp) } - val density = LocalDensity.current + items(messages, { it.id }) { message -> + MessageBubble( + message = message, + isAuthor = message.user_id == ApiClient.user!!.id, + hazeState = hazeState, + onTap = { offset -> + menuMessageId = message.id + menuOffset = offset + }, + onEdit = { + editingMessage = message + showEditDialog = true + menuMessageId = null + }, + onDelete = { + deletingMessageId = message.id + showDeleteDialog = true + menuMessageId = null + }, + onReply = { + replyingToMessage = message + menuMessageId = null + } + ) + } - Row( + if (typingUsers.isNotEmpty()) { + item { + TypingIndicator( + users = typingUsers.values.toList(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + } + } + + if (menuMessageId != null) { + val message = messages.find { it.id == menuMessageId } + if (message != null) { + MessageMenu( + message = message, + offset = menuOffset, + onDismiss = { menuMessageId = null }, + onEdit = { + editingMessage = message + showEditDialog = true + menuMessageId = null + }, + onDelete = { + deletingMessageId = message.id + showDeleteDialog = true + menuMessageId = null + }, + onReply = { + replyingToMessage = message + menuMessageId = null + }, + hazeState = hazeState + ) + } + } + } + + AnimatedVisibility( + visible = loading, + enter = EnterTransition.None, + exit = fadeOut() + ) { + Box( + Modifier + .background(MaterialTheme.colorScheme.surface) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + LoadingIndicator(Modifier.size(70.dp)) + } + } + } + + 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 - .padding(horizontal = 16.dp, vertical = 4.dp) .fillMaxWidth() - .onSizeChanged { - rowWidth = with(density) { - it.width.toDp() - } - }, - horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start + .padding(bottom = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) + ) ) { - val background = - if (isAuthor) - MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainer - Column( - 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 - ) - ) - .background(background) - .padding(10.dp) - .width(IntrinsicSize.Max) - .widthIn(max = rowWidth * 0.80f) + modifier = Modifier.padding(8.dp) ) { - 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) - ) - } - } + 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 + ) } } } - items(messages, { it.id }) { - Message( - text = it.content, - isAuthor = it.username == ApiClient.user!!.username, - isRead = it.is_read, - timestamp = Instant - .parse(it.utcTimestamp) - .toLocalDateTime(TimeZone.currentSystemDefault()) - .format(DATETIME_FORMAT), - username = it.username + if (!isAuthor) { + Text( + text = message.username, + fontWeight = FontWeight.W600, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - } - - AnimatedVisibility( - visible = loading, - enter = EnterTransition.None, - exit = fadeOut() - ) { - Box( - Modifier - .background(MaterialTheme.colorScheme.surface) - .fillMaxSize(), - contentAlignment = Alignment.Center + Text(message.content) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() ) { - LoadingIndicator(Modifier.size(70.dp)) + 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) + ) + } } } } } -} \ No newline at end of file +} + +@Composable +fun TypingIndicator( + users: List, + 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") + } + } + ) +} diff --git a/app/src/main/java/ru/fromchat/ui/Theme.kt b/app/src/main/java/ru/fromchat/ui/Theme.kt index 0d91be9..940808d 100644 --- a/app/src/main/java/ru/fromchat/ui/Theme.kt +++ b/app/src/main/java/ru/fromchat/ui/Theme.kt @@ -12,45 +12,45 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF91CEF4), - onPrimary = Color(0xFF00344A), - primaryContainer = Color(0xFF004C6A), - onPrimaryContainer = Color(0xFFC5E7FF), - secondary = Color(0xFFB6C9D8), - onSecondary = Color(0xFF20333E), - secondaryContainer = Color(0xFF374955), - onSecondaryContainer = Color(0xFFD2E5F4), - tertiary = Color(0xFFCBC1E9), - onTertiary = Color(0xFF332C4C), - tertiaryContainer = Color(0xFF494263), - onTertiaryContainer = Color(0xFFE7DEFF), + primary = Color(0xFFDBB9F9), + onPrimary = Color(0xFF3E2458), + primaryContainer = Color(0xFF563B71), + onPrimaryContainer = Color(0xFFF0DBFF), + secondary = Color(0xFFD0C1DA), + onSecondary = Color(0xFF362C3F), + secondaryContainer = Color(0xFF4D4356), + onSecondaryContainer = Color(0xFFEDDDF6), + tertiary = Color(0xFFF3B7BE), + onTertiary = Color(0xFF4B252B), + tertiaryContainer = Color(0xFF653A40), + onTertiaryContainer = Color(0xFFFFD9DD), error = Color(0xFFFFB4AB), onError = Color(0xFF690005), errorContainer = Color(0xFF93000A), onErrorContainer = Color(0xFFFFDAD6), - background = Color(0xFF0F1417), - onBackground = Color(0xFFDFE3E7), - surface = Color(0xFF0F1417), - onSurface = Color(0xFFDFE3E7), - surfaceVariant = Color(0xFF41484D), - onSurfaceVariant = Color(0xFFC1C7CE), - outline = Color(0xFF8B9297), - outlineVariant = Color(0xFF41484D), + background = Color(0xFF151218), + onBackground = Color(0xFFE8E0E8), + surface = Color(0xFF151218), + onSurface = Color(0xFFE8E0E8), + surfaceVariant = Color(0xFF4A454E), + onSurfaceVariant = Color(0xFFCCC4CE), + outline = Color(0xFF968E98), + outlineVariant = Color(0xFF4A454E), scrim = Color(0xFF000000), - inverseSurface = Color(0xFFDFE3E7), - inverseOnSurface = Color(0xFF2C3134), - inversePrimary = Color(0xFF1E6586), - surfaceDim = Color(0xFF0F1417), - surfaceBright = Color(0xFF353A3D), - surfaceContainerLowest = Color(0xFF0A0F12), - surfaceContainerLow = Color(0xFF181C1F), - surfaceContainer = Color(0xFF1C2023), - surfaceContainerHigh = Color(0xFF262B2E), - surfaceContainerHighest = Color(0xFF313539), + inverseSurface = Color(0xFFE8E0E8), + inverseOnSurface = Color(0xFF332F35), + inversePrimary = Color(0xFF6F528A), + surfaceDim = Color(0xFF151218), + surfaceBright = Color(0xFF3C383E), + surfaceContainerLowest = Color(0xFF100D12), + surfaceContainerLow = Color(0xFF1E1A20), + surfaceContainer = Color(0xFF221E24), + surfaceContainerHigh = Color(0xFF2C292E), + surfaceContainerHighest = Color(0xFF373339), ) private val LightColorScheme = lightColorScheme( - primary = Color(0xFF1E6586), + primary = Color(0xFF1F6586), onPrimary = Color(0xFFFFFFFF), primaryContainer = Color(0xFFC5E7FF), onPrimaryContainer = Color(0xFF004C6A), diff --git a/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt b/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt index 9d3eafc..639a1dd 100644 --- a/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt +++ b/app/src/main/java/ru/fromchat/ui/auth/LoginScreen.kt @@ -33,6 +33,7 @@ import ru.fromchat.api.ApiClient import ru.fromchat.api.LoginRequest import ru.fromchat.api.apiRequest import ru.fromchat.ui.RowHeader +import ru.fromchat.utils.HashUtils @Composable fun LoginScreen( @@ -95,14 +96,17 @@ fun LoginScreen( return@Button } + // Derive auth secret before sending (matches frontend implementation) scope.launch { + val derived = HashUtils.deriveAuthSecret(username.trim(), password.trim()) + apiRequest( onError = { message, _ -> alert = message }, onSuccess = { onLoginSuccess() } ) { - ApiClient.login(LoginRequest(username.trim(), password.trim())) + ApiClient.login(LoginRequest(username.trim(), derived)) } } } diff --git a/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt b/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt index b45f410..9f2cef4 100644 --- a/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt +++ b/app/src/main/java/ru/fromchat/ui/auth/RegisterScreen.kt @@ -37,6 +37,7 @@ import ru.fromchat.api.RegisterRequest import ru.fromchat.api.apiRequest import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.RowHeader +import ru.fromchat.utils.HashUtils @Composable fun RegisterScreen( @@ -44,6 +45,7 @@ fun RegisterScreen( ) { Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding -> var username by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") } var alert by remember { mutableStateOf(null) } @@ -82,6 +84,13 @@ fun RegisterScreen( singleLine = true ) + OutlinedTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text("Display Name") }, + singleLine = true + ) + OutlinedTextField( value = password, onValueChange = { password = it }, @@ -116,7 +125,7 @@ fun RegisterScreen( Button( onClick = { // Checks - if (username.isBlank() || password.isBlank() || confirmPassword.isBlank()) { + if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) { alert = alert_error_filling return@Button } @@ -128,13 +137,19 @@ fun RegisterScreen( alert = alert_error_name_little 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) { alert = alert_error_password_little return@Button } - // Send the register request + // Derive auth secret before sending (matches frontend implementation) scope.launch { + val derived = HashUtils.deriveAuthSecret(username.trim(), password) + apiRequest( onError = { message, _ -> alert = message @@ -143,9 +158,10 @@ fun RegisterScreen( ) { ApiClient.register( RegisterRequest( - username, - password, - confirmPassword + username.trim(), + displayName.trim(), + derived, + derived ) ) } diff --git a/app/src/main/java/ru/fromchat/utils/HashUtils.kt b/app/src/main/java/ru/fromchat/utils/HashUtils.kt new file mode 100644 index 0000000..5e4d378 --- /dev/null +++ b/app/src/main/java/ru/fromchat/utils/HashUtils.kt @@ -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() + 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 + } +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f729aa..929b405 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ ktor = "3.3.0" kotlinxSerializationJson = "1.9.0" compose = "1.9.2" material3 = "1.5.0-alpha04" +haze = "0.7.0" [libraries] 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" } 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" } +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }