feat: Increase delay for clearing reply messages to improve user experience

This commit is contained in:
k1ngsterr1
2026-01-15 17:48:07 +05:00
parent a939054c54
commit 842bd4eedb
3 changed files with 401 additions and 2 deletions

View File

@@ -866,7 +866,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Очищаем reply ПОСЛЕ добавления сообщения в список с небольшой задержкой // Очищаем reply ПОСЛЕ добавления сообщения в список с небольшой задержкой
viewModelScope.launch { viewModelScope.launch {
kotlinx.coroutines.delay(100) kotlinx.coroutines.delay(300)
clearReplyMessages() clearReplyMessages()
} }

View File

@@ -18,11 +18,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -204,6 +207,11 @@ fun ChatsListScreen(
// Header сразу visible = true, без анимации при возврате из чата // Header сразу visible = true, без анимации при возврате из чата
var visible by rememberSaveable { mutableStateOf(true) } var visible by rememberSaveable { mutableStateOf(true) }
// Confirmation dialogs state
var dialogToDelete by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
// Dev console dialog - commented out for now // Dev console dialog - commented out for now
/* /*
if (showDevConsole) { if (showDevConsole) {
@@ -509,13 +517,31 @@ fun ChatsListScreen(
// Show dialogs list // Show dialogs list
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(dialogsList, key = { it.opponentKey }) { dialog -> items(dialogsList, key = { it.opponentKey }) { dialog ->
DialogItem( val isSavedMessages = dialog.opponentKey == accountPublicKey
// Check if user is blocked
var isBlocked by remember { mutableStateOf(false) }
LaunchedEffect(dialog.opponentKey) {
isBlocked = chatsViewModel.isUserBlocked(dialog.opponentKey)
}
SwipeableDialogItem(
dialog = dialog, dialog = dialog,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isTyping = typingUsers.contains(dialog.opponentKey), isTyping = typingUsers.contains(dialog.opponentKey),
isBlocked = isBlocked,
isSavedMessages = isSavedMessages,
onClick = { onClick = {
val user = chatsViewModel.dialogToSearchUser(dialog) val user = chatsViewModel.dialogToSearchUser(dialog)
onUserSelect(user) onUserSelect(user)
},
onDelete = {
dialogToDelete = dialog
},
onBlock = {
dialogToBlock = dialog
},
onUnblock = {
dialogToUnblock = dialog
} }
) )
} }
@@ -526,6 +552,170 @@ fun ChatsListScreen(
} }
} }
} // Close ModalNavigationDrawer } // Close ModalNavigationDrawer
// 🔥 Confirmation Dialogs
// Delete Dialog Confirmation
dialogToDelete?.let { dialog ->
AlertDialog(
onDismissRequest = { dialogToDelete = null },
icon = {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(32.dp)
)
},
title = {
Text(
text = "Delete conversation?",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp
)
},
text = {
val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
Text(
text = "All messages with $displayName will be permanently deleted. This action cannot be undone.",
fontSize = 15.sp,
color = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF666666)
)
},
confirmButton = {
TextButton(
onClick = {
scope.launch {
chatsViewModel.deleteDialog(dialog.opponentKey)
dialogToDelete = null
}
},
colors = ButtonDefaults.textButtonColors(
contentColor = Color(0xFFFF3B30)
)
) {
Text("Delete", fontWeight = FontWeight.SemiBold)
}
},
dismissButton = {
TextButton(
onClick = { dialogToDelete = null }
) {
Text("Cancel", fontWeight = FontWeight.Medium)
}
},
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
shape = RoundedCornerShape(16.dp)
)
}
// Block Dialog Confirmation
dialogToBlock?.let { dialog ->
AlertDialog(
onDismissRequest = { dialogToBlock = null },
icon = {
Icon(
imageVector = Icons.Default.Block,
contentDescription = null,
tint = Color(0xFFFF6B6B),
modifier = Modifier.size(32.dp)
)
},
title = {
Text(
text = "Block user?",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp
)
},
text = {
val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
Text(
text = "$displayName will no longer be able to send you messages. You can unblock them later.",
fontSize = 15.sp,
color = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF666666)
)
},
confirmButton = {
TextButton(
onClick = {
scope.launch {
chatsViewModel.blockUser(dialog.opponentKey)
dialogToBlock = null
}
},
colors = ButtonDefaults.textButtonColors(
contentColor = Color(0xFFFF3B30)
)
) {
Text("Block", fontWeight = FontWeight.SemiBold)
}
},
dismissButton = {
TextButton(
onClick = { dialogToBlock = null }
) {
Text("Cancel", fontWeight = FontWeight.Medium)
}
},
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
shape = RoundedCornerShape(16.dp)
)
}
// Unblock Dialog Confirmation
dialogToUnblock?.let { dialog ->
AlertDialog(
onDismissRequest = { dialogToUnblock = null },
icon = {
Icon(
imageVector = Icons.Default.LockOpen,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(32.dp)
)
},
title = {
Text(
text = "Unblock user?",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp
)
},
text = {
val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
Text(
text = "$displayName will be able to send you messages again.",
fontSize = 15.sp,
color = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF666666)
)
},
confirmButton = {
TextButton(
onClick = {
scope.launch {
chatsViewModel.unblockUser(dialog.opponentKey)
dialogToUnblock = null
}
},
colors = ButtonDefaults.textButtonColors(
contentColor = Color(0xFF4CAF50)
)
) {
Text("Unblock", fontWeight = FontWeight.SemiBold)
}
},
dismissButton = {
TextButton(
onClick = { dialogToUnblock = null }
) {
Text("Cancel", fontWeight = FontWeight.Medium)
}
},
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
shape = RoundedCornerShape(16.dp)
)
}
} // Close Box } // Close Box
} }
@@ -791,6 +981,155 @@ fun DrawerMenuItem(
} }
} }
/**
* 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete
* Свайп влево показывает действия (как в React Native версии)
*/
@Composable
fun SwipeableDialogItem(
dialog: DialogUiModel,
isDarkTheme: Boolean,
isTyping: Boolean = false,
isBlocked: Boolean = false,
isSavedMessages: Boolean = false,
onClick: () -> Unit,
onDelete: () -> Unit = {},
onBlock: () -> Unit = {},
onUnblock: () -> Unit = {}
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
var offsetX by remember { mutableStateOf(0f) }
val swipeWidthDp = if (isSavedMessages) 80.dp else 160.dp
val density = androidx.compose.ui.platform.LocalDensity.current
val swipeWidthPx = with(density) { swipeWidthDp.toPx() }
// Фиксированная высота элемента (как в DialogItem)
val itemHeight = 80.dp
// Анимация возврата
val animatedOffsetX by animateFloatAsState(
targetValue = offsetX,
animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f),
label = "swipeOffset"
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.clipToBounds()
) {
// 1. КНОПКИ - позиционированы справа, всегда видны при свайпе
Row(
modifier = Modifier
.align(Alignment.CenterEnd)
.height(itemHeight)
.width(swipeWidthDp)
) {
// Кнопка Block/Unblock (только если не Saved Messages)
if (!isSavedMessages) {
Box(
modifier = Modifier
.width(80.dp)
.fillMaxHeight()
.background(if (isBlocked) Color(0xFF4CAF50) else Color(0xFFFF6B6B))
.clickable {
if (isBlocked) onUnblock() else onBlock()
offsetX = 0f
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = if (isBlocked) Icons.Default.LockOpen else Icons.Default.Block,
contentDescription = if (isBlocked) "Unblock" else "Block",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (isBlocked) "Unblock" else "Block",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
// Кнопка Delete
Box(
modifier = Modifier
.width(80.dp)
.fillMaxHeight()
.background(PrimaryBlue)
.clickable {
onDelete()
offsetX = 0f
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Delete",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
Box(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(animatedOffsetX.toInt(), 0) }
.background(backgroundColor)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
// Если свайпнули больше чем на половину - фиксируем
if (kotlin.math.abs(offsetX) > swipeWidthPx / 2) {
offsetX = -swipeWidthPx
} else {
offsetX = 0f
}
},
onDragCancel = {
offsetX = 0f
},
onHorizontalDrag = { _, dragAmount ->
// Только свайп влево (отрицательное значение)
val newOffset = offsetX + dragAmount
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
}
)
}
) {
DialogItem(
dialog = dialog,
isDarkTheme = isDarkTheme,
isTyping = isTyping,
onClick = onClick
)
}
}
}
/** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */ /** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */
@Composable @Composable
fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, onClick: () -> Unit) { fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, onClick: () -> Unit) {

View File

@@ -168,4 +168,64 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
online = dialog.isOnline online = dialog.isOnline
) )
} }
/**
* Удалить диалог и все сообщения с собеседником
*/
suspend fun deleteDialog(opponentKey: String) {
if (currentAccount.isEmpty()) return
try {
// Удаляем все сообщения
database.messageDao().deleteDialog(currentAccount, opponentKey)
// Удаляем диалог
database.dialogDao().deleteDialog(currentAccount, opponentKey)
} catch (e: Exception) {
android.util.Log.e("ChatsListViewModel", "Error deleting dialog", e)
}
}
/**
* Заблокировать пользователя
*/
suspend fun blockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
try {
database.blacklistDao().blockUser(
com.rosetta.messenger.database.BlacklistEntity(
publicKey = publicKey,
account = currentAccount
)
)
} catch (e: Exception) {
android.util.Log.e("ChatsListViewModel", "Error blocking user", e)
}
}
/**
* Разблокировать пользователя
*/
suspend fun unblockUser(publicKey: String) {
if (currentAccount.isEmpty()) return
try {
database.blacklistDao().unblockUser(publicKey, currentAccount)
} catch (e: Exception) {
android.util.Log.e("ChatsListViewModel", "Error unblocking user", e)
}
}
/**
* Проверить заблокирован ли пользователь
*/
suspend fun isUserBlocked(publicKey: String): Boolean {
if (currentAccount.isEmpty()) return false
return try {
database.blacklistDao().isUserBlocked(publicKey, currentAccount)
} catch (e: Exception) {
false
}
}
} }