feat: Increase delay for clearing reply messages to improve user experience
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user