feat: Enhance ChatDetailScreen with Telegram-style selection UI and action buttons
This commit is contained in:
@@ -679,10 +679,12 @@ object MessageCrypto {
|
|||||||
// 1. Расшифровываем ChaCha ключ (как для сообщений)
|
// 1. Расшифровываем ChaCha ключ (как для сообщений)
|
||||||
val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)
|
val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey)
|
||||||
|
|
||||||
// 2. Конвертируем key+nonce в строку (как в RN: key.toString('utf-8'))
|
// 2. Конвертируем key+nonce в строку используя bytesToJsUtf8String
|
||||||
val chachaKeyString = String(keyAndNonce, Charsets.UTF_8)
|
// чтобы совпадало с JS Buffer.toString('utf-8') который заменяет
|
||||||
|
// невалидные UTF-8 последовательности на U+FFFD
|
||||||
|
val chachaKeyString = bytesToJsUtf8String(keyAndNonce)
|
||||||
|
|
||||||
// 3. Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha256)
|
// 3. Генерируем PBKDF2 ключ (salt='rosetta', 1000 iterations, sha1)
|
||||||
val pbkdf2Key = generatePBKDF2Key(chachaKeyString)
|
val pbkdf2Key = generatePBKDF2Key(chachaKeyString)
|
||||||
|
|
||||||
// 4. Расшифровываем AES-256-CBC
|
// 4. Расшифровываем AES-256-CBC
|
||||||
@@ -694,10 +696,11 @@ object MessageCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Генерация PBKDF2 ключа (совместимо с RN)
|
* Генерация PBKDF2 ключа (совместимо с crypto-js / RN)
|
||||||
|
* ВАЖНО: crypto-js использует PBKDF2WithHmacSHA1 по умолчанию!
|
||||||
*/
|
*/
|
||||||
private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray {
|
private fun generatePBKDF2Key(password: String, salt: String = "rosetta", iterations: Int = 1000): ByteArray {
|
||||||
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
|
||||||
val spec = javax.crypto.spec.PBEKeySpec(
|
val spec = javax.crypto.spec.PBEKeySpec(
|
||||||
password.toCharArray(),
|
password.toCharArray(),
|
||||||
salt.toByteArray(Charsets.UTF_8),
|
salt.toByteArray(Charsets.UTF_8),
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ fun ChatDetailScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0.dp),
|
contentWindowInsets = WindowInsets(0.dp),
|
||||||
topBar = {
|
topBar = {
|
||||||
// 🔥 SELECTION HEADER (появляется при выборе сообщений)
|
// 🔥 SELECTION HEADER (появляется при выборе сообщений) - Telegram Style
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isSelectionMode,
|
visible = isSelectionMode,
|
||||||
enter = fadeIn(animationSpec = tween(200)) + slideInVertically(
|
enter = fadeIn(animationSpec = tween(200)) + slideInVertically(
|
||||||
@@ -451,55 +451,81 @@ fun ChatDetailScreen(
|
|||||||
) {
|
) {
|
||||||
Box(modifier = Modifier
|
Box(modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7))
|
.background(if (isDarkTheme) Color(0xFF212121) else Color.White)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.height(56.dp)
|
.height(56.dp)
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
// Left: X (cancel) + Count
|
// Left: X (cancel) + Count - Telegram Style
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = { selectedMessages = emptySet() }) {
|
IconButton(onClick = { selectedMessages = emptySet() }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
Icons.Default.Close,
|
||||||
contentDescription = "Cancel",
|
contentDescription = "Cancel",
|
||||||
tint = textColor
|
tint = if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"${selectedMessages.size}",
|
"${selectedMessages.size}",
|
||||||
fontSize = 18.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor
|
color = if (isDarkTheme) Color.White else Color.Black
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right: Copy button only
|
// Right: Action buttons - Telegram Style
|
||||||
IconButton(
|
Row(
|
||||||
onClick = {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
// Копируем текст выбранных сообщений
|
verticalAlignment = Alignment.CenterVertically
|
||||||
val textToCopy = messages
|
|
||||||
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
|
||||||
.sortedBy { it.timestamp }
|
|
||||||
.joinToString("\n\n") { msg ->
|
|
||||||
val time = SimpleDateFormat("HH:mm", Locale.getDefault())
|
|
||||||
.format(msg.timestamp)
|
|
||||||
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}"
|
|
||||||
}
|
|
||||||
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(textToCopy))
|
|
||||||
selectedMessages = emptySet()
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
// Copy button
|
||||||
Icons.Default.ContentCopy,
|
IconButton(
|
||||||
contentDescription = "Copy",
|
onClick = {
|
||||||
tint = textColor
|
val textToCopy = messages
|
||||||
)
|
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
||||||
|
.sortedBy { it.timestamp }
|
||||||
|
.joinToString("\n\n") { msg ->
|
||||||
|
val time = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
|
.format(msg.timestamp)
|
||||||
|
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}"
|
||||||
|
}
|
||||||
|
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(textToCopy))
|
||||||
|
selectedMessages = emptySet()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy",
|
||||||
|
tint = if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
// Удаляем выбранные сообщения
|
||||||
|
messages
|
||||||
|
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
||||||
|
.forEach { msg -> viewModel.deleteMessage(msg.id) }
|
||||||
|
selectedMessages = emptySet()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Delete",
|
||||||
|
tint = if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,11 +1126,12 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
// padding для контента списка - минимальные отступы
|
// padding для контента списка
|
||||||
|
// 🔥 Убираем horizontal padding чтобы выделение было edge-to-edge
|
||||||
// 🔥 Увеличиваем bottom padding когда активен selection mode (Reply/Forward панель)
|
// 🔥 Увеличиваем bottom padding когда активен selection mode (Reply/Forward панель)
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 8.dp,
|
start = 0.dp,
|
||||||
end = 8.dp,
|
end = 0.dp,
|
||||||
top = 8.dp,
|
top = 8.dp,
|
||||||
bottom = if (isSelectionMode) 140.dp else 80.dp
|
bottom = if (isSelectionMode) 140.dp else 80.dp
|
||||||
),
|
),
|
||||||
@@ -1553,42 +1580,72 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 TELEGRAM STYLE: Полупрозрачный синий фон для выбранных сообщений
|
||||||
|
val selectionBackgroundColor by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent,
|
||||||
|
animationSpec = tween(200),
|
||||||
|
label = "selectionBg"
|
||||||
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
// 🔥 TELEGRAM: horizontal 4dp (меньше), vertical 2dp (компактнее)
|
// 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину экрана!
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.background(selectionBackgroundColor)
|
||||||
.offset { IntOffset(animatedOffset.toInt(), 0) }
|
// 🔥 Только vertical padding, horizontal убран чтобы выделение было edge-to-edge
|
||||||
.graphicsLayer {
|
.padding(vertical = 2.dp)
|
||||||
this.alpha = selectionAlpha
|
.offset { IntOffset(animatedOffset.toInt(), 0) },
|
||||||
this.scaleX = selectionScale
|
horizontalArrangement = Arrangement.Start,
|
||||||
this.scaleY = selectionScale
|
verticalAlignment = Alignment.CenterVertically
|
||||||
},
|
|
||||||
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
|
|
||||||
) {
|
) {
|
||||||
// Checkbox для выбранных сообщений
|
// 🔥 TELEGRAM STYLE: Зеленая галочка СЛЕВА (как в Telegram)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isSelected,
|
visible = isSelected,
|
||||||
enter = fadeIn() + scaleIn(initialScale = 0.5f),
|
enter = fadeIn(tween(150)) + scaleIn(initialScale = 0.3f, animationSpec = spring(dampingRatio = 0.6f)),
|
||||||
exit = fadeOut() + scaleOut(targetScale = 0.5f)
|
exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(end = 8.dp)
|
.padding(start = 12.dp, end = 4.dp)
|
||||||
.align(Alignment.CenterVertically)
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CD964)), // Зеленый как в Telegram
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CheckCircle,
|
Icons.Default.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = "Selected",
|
||||||
tint = PrimaryBlue,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spacer для невыбранных сообщений (чтобы пузырьки не прыгали)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !isSelected,
|
||||||
|
enter = fadeIn(tween(100)),
|
||||||
|
exit = fadeOut(tween(100))
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.width(12.dp)) // Отступ слева когда нет галочки
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Spacer для выравнивания исходящих сообщений вправо
|
||||||
|
if (message.isOutgoing) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.widthIn(max = 280.dp) // 🔥 TELEGRAM: чуть уже (280dp)
|
Modifier
|
||||||
|
// 🔥 Добавляем горизонтальные отступы к пузырьку
|
||||||
|
.padding(end = 12.dp)
|
||||||
|
.widthIn(max = 280.dp) // 🔥 TELEGRAM: чуть уже (280dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
this.alpha = selectionAlpha
|
||||||
|
this.scaleX = selectionScale
|
||||||
|
this.scaleY = selectionScale
|
||||||
|
}
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
indication = null,
|
indication = null,
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
|||||||
Reference in New Issue
Block a user