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 ПОСЛЕ добавления сообщения в список с небольшой задержкой
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.delay(100)
|
||||
kotlinx.coroutines.delay(300)
|
||||
clearReplyMessages()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -203,6 +206,11 @@ fun ChatsListScreen(
|
||||
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
|
||||
// Header сразу visible = 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
|
||||
/*
|
||||
@@ -509,13 +517,31 @@ fun ChatsListScreen(
|
||||
// Show dialogs list
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
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,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isTyping = typingUsers.contains(dialog.opponentKey),
|
||||
isBlocked = isBlocked,
|
||||
isSavedMessages = isSavedMessages,
|
||||
onClick = {
|
||||
val user = chatsViewModel.dialogToSearchUser(dialog)
|
||||
onUserSelect(user)
|
||||
},
|
||||
onDelete = {
|
||||
dialogToDelete = dialog
|
||||
},
|
||||
onBlock = {
|
||||
dialogToBlock = dialog
|
||||
},
|
||||
onUnblock = {
|
||||
dialogToUnblock = dialog
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -526,6 +552,170 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
} // 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, onClick: () -> Unit) {
|
||||
|
||||
@@ -168,4 +168,64 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
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