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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ 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" }