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
+2 -1
View File
@@ -19,4 +19,5 @@ build
local.properties
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.serialization.json)
implementation(libs.kotlinx.serialization.json)
implementation(libs.haze)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
+2 -2
View File
@@ -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()
@@ -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<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
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,
+48 -8
View File
@@ -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)
)
}
}
}
@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.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<Message>() }
val typingUsers = remember { mutableStateMapOf<Int, String>() }
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) {
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<Message>(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<Message>(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<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)
}
} 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)
)
}
}
}
}
}
}
}
@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
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),
@@ -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))
}
}
}
@@ -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<String?>(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
)
)
}
@@ -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"
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" }