feat: Implement block/unblock user functionality with confirmation dialogs in ChatDetailScreen

This commit is contained in:
k1ngsterr1
2026-01-12 17:54:13 +05:00
parent fb339642fa
commit 9addd41571
3 changed files with 324 additions and 180 deletions

View File

@@ -21,7 +21,7 @@
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.RosettaAndroid" android:theme="@style/Theme.RosettaAndroid"
android:windowSoftInputMode="adjustNothing"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -195,6 +195,18 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE account = :account AND dialog_key = :dialogKey") @Query("DELETE FROM messages WHERE account = :account AND dialog_key = :dialogKey")
suspend fun deleteDialog(account: String, dialogKey: String) suspend fun deleteDialog(account: String, dialogKey: String)
/**
* Удалить все сообщения между двумя пользователями
*/
@Query("""
DELETE FROM messages
WHERE account = :account AND (
(from_public_key = :user1 AND to_public_key = :user2) OR
(from_public_key = :user2 AND to_public_key = :user1)
)
""")
suspend fun deleteMessagesBetweenUsers(account: String, user1: String, user2: String)
/** /**
* Количество непрочитанных сообщений в диалоге * Количество непрочитанных сообщений в диалоге
*/ */

View File

@@ -243,6 +243,13 @@ fun ChatDetailScreen(
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm by remember { mutableStateOf(false) }
// Проверяем, заблокирован ли пользователь
var isBlocked by remember { mutableStateOf(false) }
LaunchedEffect(user.publicKey, currentUserPublicKey) {
isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey)
}
// Подключаем к ViewModel // Подключаем к ViewModel
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
@@ -625,28 +632,32 @@ fun ChatDetailScreen(
} }
) )
// Block User (не показываем для Saved Messages) // Block/Unblock User (не показываем для Saved Messages)
if (!isSavedMessages) { if (!isSavedMessages) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
Icons.Default.Block, if (isBlocked) Icons.Default.Check else Icons.Default.Block,
contentDescription = null, contentDescription = null,
tint = Color(0xFFFF3B30), tint = if (isBlocked) PrimaryBlue else Color(0xFFFF3B30),
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
"Block User", if (isBlocked) "Unblock User" else "Block User",
color = Color(0xFFFF3B30), color = if (isBlocked) PrimaryBlue else Color(0xFFFF3B30),
fontSize = 16.sp fontSize = 16.sp
) )
} }
}, },
onClick = { onClick = {
showMenu = false showMenu = false
showBlockConfirm = true if (isBlocked) {
showUnblockConfirm = true
} else {
showBlockConfirm = true
}
} }
) )
} }
@@ -675,6 +686,7 @@ fun ChatDetailScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.imePadding() // ⌨️ Поднимаем контент при появлении клавиатуры
) { ) {
// Список сообщений - занимает весь экран // Список сообщений - занимает весь экран
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -901,11 +913,13 @@ fun ChatDetailScreen(
backgroundColor = inputBackgroundColor, backgroundColor = inputBackgroundColor,
textColor = textColor, textColor = textColor,
placeholderColor = secondaryTextColor, placeholderColor = secondaryTextColor,
secondaryTextColor = secondaryTextColor,
// Reply state // Reply state
replyMessages = replyMessages, replyMessages = replyMessages,
isForwardMode = isForwardMode, isForwardMode = isForwardMode,
onCloseReply = { viewModel.clearReplyMessages() }, onCloseReply = { viewModel.clearReplyMessages() },
chatTitle = chatTitle chatTitle = chatTitle,
isBlocked = isBlocked
) )
} }
@@ -1107,18 +1121,21 @@ fun ChatDetailScreen(
showDeleteConfirm = false showDeleteConfirm = false
scope.launch { scope.launch {
try { try {
// Delete all messages in this dialog // Удаляем все сообщения из диалога
database.messageDao().deleteDialog( // DELETE FROM messages WHERE ((from_public_key = ? AND to_public_key = ?) OR (from_public_key = ? AND to_public_key = ?)) AND account = ?
database.messageDao().deleteMessagesBetweenUsers(
account = currentUserPublicKey, account = currentUserPublicKey,
dialogKey = user.publicKey user1 = user.publicKey,
user2 = currentUserPublicKey
) )
// Delete dialog cache // Очищаем кеш диалога
database.dialogDao().deleteDialog( database.dialogDao().deleteDialog(
account = currentUserPublicKey, account = currentUserPublicKey,
opponentKey = user.publicKey opponentKey = user.publicKey
) )
android.util.Log.d("ChatDetail", "✅ Chat deleted with: ${user.publicKey.take(10)}")
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ChatDetail", "Error deleting chat", e) android.util.Log.e("ChatDetail", "Error deleting chat", e)
} }
} }
onBack() onBack()
@@ -1159,19 +1176,20 @@ fun ChatDetailScreen(
showBlockConfirm = false showBlockConfirm = false
scope.launch { scope.launch {
try { try {
// Add user to blacklist android.util.Log.d("ChatDetail", "🚫 Blocking user: ${user.publicKey.take(10)}")
// Добавляем пользователя в blacklist
database.blacklistDao().blockUser( database.blacklistDao().blockUser(
com.rosetta.messenger.database.BlacklistEntity( com.rosetta.messenger.database.BlacklistEntity(
publicKey = user.publicKey, publicKey = user.publicKey,
account = currentUserPublicKey account = currentUserPublicKey
) )
) )
android.util.Log.d("ChatDetail", "User blocked: ${user.publicKey.take(10)}") isBlocked = true
android.util.Log.d("ChatDetail", "✅ User blocked: ${user.publicKey.take(10)}")
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ChatDetail", "Error blocking user", e) android.util.Log.e("ChatDetail", "Error blocking user", e)
} }
} }
onBack()
} }
) { ) {
Text("Block", color = Color(0xFFFF3B30)) Text("Block", color = Color(0xFFFF3B30))
@@ -1184,6 +1202,55 @@ fun ChatDetailScreen(
} }
) )
} }
// Диалог подтверждения разблокировки
if (showUnblockConfirm) {
AlertDialog(
onDismissRequest = { showUnblockConfirm = false },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Unblock ${user.title.ifEmpty { "User" }}",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to unblock this user? They will be able to send you messages again.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
showUnblockConfirm = false
scope.launch {
try {
android.util.Log.d("ChatDetail", "✅ Unblocking user: ${user.publicKey.take(10)}")
// Удаляем пользователя из blacklist
database.blacklistDao().unblockUser(
publicKey = user.publicKey,
account = currentUserPublicKey
)
isBlocked = false
android.util.Log.d("ChatDetail", "✅ User unblocked: ${user.publicKey.take(10)}")
} catch (e: Exception) {
android.util.Log.e("ChatDetail", "❌ Error unblocking user", e)
}
}
}
) {
Text("Unblock", color = PrimaryBlue)
}
},
dismissButton = {
TextButton(onClick = { showUnblockConfirm = false }) {
Text("Cancel", color = Color(0xFF8E8E93))
}
}
)
}
} }
/** 🚀 Анимация появления сообщения Telegram-style */ /** 🚀 Анимация появления сообщения Telegram-style */
@@ -1454,11 +1521,13 @@ private fun MessageInputBar(
backgroundColor: Color, backgroundColor: Color,
textColor: Color, textColor: Color,
placeholderColor: Color, placeholderColor: Color,
secondaryTextColor: Color,
// Reply state (как в React Native) // Reply state (как в React Native)
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(), replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false, isForwardMode: Boolean = false,
onCloseReply: () -> Unit = {}, onCloseReply: () -> Unit = {},
chatTitle: String = "" chatTitle: String = "",
isBlocked: Boolean = false
) { ) {
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
var showEmojiPicker by remember { mutableStateOf(false) } var showEmojiPicker by remember { mutableStateOf(false) }
@@ -1523,62 +1592,112 @@ private fun MessageInputBar(
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
// 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT // Если пользователь заблокирован - показываем BlockedChatFooter
// Используем Column вместо Row для reply panel внутри if (isBlocked) {
Column( // BLOCKED CHAT FOOTER
modifier = Row(
Modifier.fillMaxWidth() modifier = Modifier
.padding(horizontal = 8.dp, vertical = 16.dp) .fillMaxWidth()
// Инпут растёт вверх до 6 строк (~140dp) + reply .padding(horizontal = 16.dp, vertical = 16.dp)
.heightIn(min = 44.dp, max = if (hasReply) 200.dp else 140.dp) .background(
.shadow( color = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F3F5),
elevation = 4.dp, shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(22.dp), )
clip = false, .border(
ambientColor = Color.Black.copy(alpha = 0.2f), width = 1.dp,
spotColor = Color.Black.copy(alpha = 0.2f) color = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA),
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Block,
contentDescription = null,
tint = Color(0xFFFF6B6B),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "You need to unblock user to send messages.",
fontSize = 14.sp,
color = secondaryTextColor,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
} else {
// 🔥 REACT NATIVE STYLE: Attach | Glass Input | Mic
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 16.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// ATTACH BUTTON - круглая кнопка слева
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f)
else Color(0xFFF0F0F0).copy(alpha = 0.85f)
)
.border(
width = 1.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.25f)
else Color.Black.copy(alpha = 0.1f),
shape = CircleShape
)
.clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO: Attach */ },
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Attachment,
contentDescription = "Attach",
tint = if (isDarkTheme) Color.White else Color(0xFF333333),
modifier = Modifier.size(22.dp).graphicsLayer { rotationZ = -45f }
)
}
// GLASS INPUT - расширяется, содержит Emoji + TextField + Send
Column(
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = if (hasReply) 200.dp else 140.dp)
.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(if (hasReply) 16.dp else 22.dp),
clip = false,
ambientColor = Color.Black.copy(alpha = 0.2f),
spotColor = Color.Black.copy(alpha = 0.2f)
)
.clip(RoundedCornerShape(if (hasReply) 16.dp else 22.dp))
.background(
brush = Brush.verticalGradient(
colors = if (isDarkTheme) {
listOf(
Color(0xFF3C3C3C).copy(alpha = 0.9f),
Color(0xFF2C2C2C).copy(alpha = 0.95f)
) )
.clip(RoundedCornerShape(22.dp)) } else {
.background( listOf(
brush = Color(0xFFF0F0F0).copy(alpha = 0.92f),
Brush.verticalGradient( Color(0xFFE8E8E8).copy(alpha = 0.96f)
colors =
if (isDarkTheme) {
listOf(
Color(0xFF2D2D2F)
.copy(alpha = 0.92f),
Color(0xFF1C1C1E)
.copy(alpha = 0.96f)
)
} else {
listOf(
Color(0xFFF2F2F7)
.copy(alpha = 0.94f),
Color(0xFFE5E5EA)
.copy(alpha = 0.97f)
)
}
)
)
.border(
width = 1.dp,
brush =
Brush.verticalGradient(
colors =
if (isDarkTheme) {
listOf(
Color.White.copy(alpha = 0.18f),
Color.White.copy(alpha = 0.06f)
)
} else {
listOf(
Color.White.copy(alpha = 0.9f),
Color.Black.copy(alpha = 0.05f)
)
}
),
shape = RoundedCornerShape(22.dp)
) )
}
)
)
.border(
width = 1.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.25f)
else Color.Black.copy(alpha = 0.1f),
shape = RoundedCornerShape(if (hasReply) 16.dp else 22.dp)
)
) { ) {
// 🔥 REPLY PANEL внутри glass (как в React Native) // 🔥 REPLY PANEL внутри glass (как в React Native)
AnimatedVisibility( AnimatedVisibility(
@@ -1642,136 +1761,148 @@ private fun MessageInputBar(
} }
} }
// Input Row // Input Row внутри Glass
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 4.dp), .padding(start = 14.dp, end = 6.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
// EMOJI BUTTON
Box(
modifier =
Modifier.align(Alignment.Bottom)
.size(36.dp)
.clip(CircleShape)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { toggleEmojiPicker() }
),
contentAlignment = Alignment.Center
) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard
else Icons.Default.SentimentSatisfiedAlt,
contentDescription = "Emoji",
tint =
if (showEmojiPicker) PrimaryBlue
else {
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
else Color.Black.copy(alpha = 0.55f)
},
modifier = Modifier.size(26.dp)
)
}
// TEXT INPUT // TEXT INPUT
Box( Box(
modifier = modifier = Modifier
Modifier.weight(1f) .weight(1f)
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically),
.padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart
contentAlignment = Alignment.CenterStart
) { ) {
AppleEmojiTextField( AppleEmojiTextField(
value = value, value = value,
onValueChange = { newValue -> onValueChange(newValue) }, onValueChange = { newValue -> onValueChange(newValue) },
textColor = textColor, textColor = textColor,
textSize = 17f, textSize = 16f,
hint = "Message", hint = "Message",
hintColor = hintColor = if (isDarkTheme) Color.White.copy(alpha = 0.35f)
if (isDarkTheme) Color.White.copy(alpha = 0.35f) else Color.Black.copy(alpha = 0.35f),
else Color.Black.copy(alpha = 0.35f), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) )
} }
// ATTACH BUTTON // Right Zone: Emoji + Send (как в React Native)
Box( Box(
modifier = modifier = Modifier
Modifier.align(Alignment.Bottom) .align(Alignment.Bottom)
.size(36.dp) .padding(bottom = 8.dp),
.clip(CircleShape) contentAlignment = Alignment.BottomEnd
.clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO: Attach */},
contentAlignment = Alignment.Center
) { ) {
Icon( Row(
Icons.Default.Attachment, horizontalArrangement = Arrangement.spacedBy(4.dp),
contentDescription = "Attach", verticalAlignment = Alignment.Bottom
tint = ) {
if (isDarkTheme) Color.White.copy(alpha = 0.65f) // EMOJI BUTTON
else Color.Black.copy(alpha = 0.55f), Box(
modifier = Modifier.size(24.dp).graphicsLayer { rotationZ = -45f } modifier = Modifier
) .size(36.dp)
} .clip(CircleShape)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { toggleEmojiPicker() }
),
contentAlignment = Alignment.Center
) {
Icon(
if (showEmojiPicker) Icons.Default.Keyboard
else Icons.Default.SentimentSatisfiedAlt,
contentDescription = "Emoji",
tint = if (isDarkTheme) Color.White.copy(alpha = 0.62f)
else Color.Black.copy(alpha = 0.5f),
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(2.dp)) // SEND BUTTON - только когда canSend
androidx.compose.animation.AnimatedVisibility(
// MIC / SEND BUTTON visible = canSend,
Box( enter = fadeIn(tween(200)) + scaleIn(
modifier = initialScale = 0.5f,
Modifier.align(Alignment.Bottom) animationSpec = tween(220, easing = FastOutSlowInEasing)
.size(36.dp) ),
.clip(CircleShape) exit = fadeOut(tween(200)) + scaleOut(
.then( targetScale = 0.5f,
if (canSend) { animationSpec = tween(220)
Modifier.background(PrimaryBlue) )
} else { ) {
Modifier Box(
} modifier = Modifier
) .width(52.dp)
.height(34.dp)
.clip(RoundedCornerShape(17.dp))
.background(PrimaryBlue)
.clickable( .clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = null, indication = null,
onClick = { onClick = { handleSend() }
if (canSend) handleSend()
else { /* TODO: Voice recording */ }
}
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
androidx.compose.animation.Crossfade( Icon(
targetState = canSend, imageVector = TelegramSendIcon,
animationSpec = tween(150), contentDescription = "Send",
label = "iconCrossfade" tint = Color.White,
) { showSend -> modifier = Modifier.size(18.dp)
if (showSend) { )
Icon( }
imageVector = TelegramSendIcon, }
contentDescription = "Send",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
} else {
Icon(
Icons.Default.Mic,
contentDescription = "Voice",
tint =
if (isDarkTheme) Color.White.copy(alpha = 0.65f)
else Color.Black.copy(alpha = 0.55f),
modifier = Modifier.size(22.dp)
)
} }
} }
}
} // End of Input Row } // End of Input Row
} // End of Glass Column } // End of Glass Column
// Apple Emoji Picker // MIC BUTTON - справа снаружи (как в React Native)
androidx.compose.animation.AnimatedVisibility(
visible = !canSend,
enter = fadeIn(tween(200)) + slideInHorizontally(
initialOffsetX = { it / 2 },
animationSpec = tween(250)
),
exit = fadeOut(tween(200)) + slideOutHorizontally(
targetOffsetX = { it / 2 },
animationSpec = tween(250)
)
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (isDarkTheme) Color(0xFF3C3C3C).copy(alpha = 0.8f)
else Color(0xFFF0F0F0).copy(alpha = 0.85f)
)
.border(
width = 1.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.25f)
else Color.Black.copy(alpha = 0.1f),
shape = CircleShape
)
.clickable(
interactionSource = interactionSource,
indication = null
) { /* TODO: Voice recording */ },
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Mic,
contentDescription = "Voice",
tint = if (isDarkTheme) Color.White else Color(0xFF333333),
modifier = Modifier.size(22.dp)
)
}
}
} // End of outer Row
} // End of else (not blocked)
// Apple Emoji Picker - только показываем если не заблокирован
if (!isBlocked) {
AnimatedVisibility( AnimatedVisibility(
visible = showEmojiPicker, visible = showEmojiPicker,
enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(),
@@ -1783,6 +1914,7 @@ private fun MessageInputBar(
onClose = { showEmojiPicker = false } onClose = { showEmojiPicker = false }
) )
} }
} // End of if (!isBlocked) for emoji picker
} }
} }