From 01441e21d9d8ba71cad569e6f1cc0fc46234842b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 14 Jan 2026 01:39:44 +0500 Subject: [PATCH] feat: Enhance ChatDetailScreen with Telegram-style selection UI and action buttons --- .../rosetta/messenger/crypto/MessageCrypto.kt | 13 +- .../messenger/ui/chats/ChatDetailScreen.kt | 155 ++++++++++++------ 2 files changed, 114 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 22b25b3..d8622b3 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -679,10 +679,12 @@ object MessageCrypto { // 1. Расшифровываем ChaCha ключ (как для сообщений) val keyAndNonce = decryptKeyFromSender(encryptedKey, myPrivateKey) - // 2. Конвертируем key+nonce в строку (как в RN: key.toString('utf-8')) - val chachaKeyString = String(keyAndNonce, Charsets.UTF_8) + // 2. Конвертируем key+nonce в строку используя bytesToJsUtf8String + // чтобы совпадало с 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) // 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 { - val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") val spec = javax.crypto.spec.PBEKeySpec( password.toCharArray(), salt.toByteArray(Charsets.UTF_8), diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 7d423eb..6ca462b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -437,7 +437,7 @@ fun ChatDetailScreen( Scaffold( contentWindowInsets = WindowInsets(0.dp), topBar = { - // 🔥 SELECTION HEADER (появляется при выборе сообщений) + // 🔥 SELECTION HEADER (появляется при выборе сообщений) - Telegram Style AnimatedVisibility( visible = isSelectionMode, enter = fadeIn(animationSpec = tween(200)) + slideInVertically( @@ -451,55 +451,81 @@ fun ChatDetailScreen( ) { Box(modifier = Modifier .fillMaxWidth() - .background(if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)) + .background(if (isDarkTheme) Color(0xFF212121) else Color.White) ) { Row( modifier = Modifier .fillMaxWidth() .statusBarsPadding() .height(56.dp) - .padding(horizontal = 8.dp), + .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - // Left: X (cancel) + Count + // Left: X (cancel) + Count - Telegram Style Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = { selectedMessages = emptySet() }) { Icon( Icons.Default.Close, contentDescription = "Cancel", - tint = textColor + tint = if (isDarkTheme) Color.White else Color.Black, + modifier = Modifier.size(24.dp) ) } + Spacer(modifier = Modifier.width(8.dp)) Text( "${selectedMessages.size}", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = textColor + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = if (isDarkTheme) Color.White else Color.Black ) } - // Right: Copy button only - IconButton( - onClick = { - // Копируем текст выбранных сообщений - 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() - } + // Right: Action buttons - Telegram Style + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - Icons.Default.ContentCopy, - contentDescription = "Copy", - tint = textColor - ) + // Copy button + IconButton( + onClick = { + 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 панель) contentPadding = PaddingValues( - start = 8.dp, - end = 8.dp, + start = 0.dp, + end = 0.dp, top = 8.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( modifier = Modifier.fillMaxWidth() - // 🔥 TELEGRAM: horizontal 4dp (меньше), vertical 2dp (компактнее) - .padding(horizontal = 4.dp, vertical = 2.dp) - .offset { IntOffset(animatedOffset.toInt(), 0) } - .graphicsLayer { - this.alpha = selectionAlpha - this.scaleX = selectionScale - this.scaleY = selectionScale - }, - horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start + // 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину экрана! + .background(selectionBackgroundColor) + // 🔥 Только vertical padding, horizontal убран чтобы выделение было edge-to-edge + .padding(vertical = 2.dp) + .offset { IntOffset(animatedOffset.toInt(), 0) }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically ) { - // Checkbox для выбранных сообщений + // 🔥 TELEGRAM STYLE: Зеленая галочка СЛЕВА (как в Telegram) AnimatedVisibility( visible = isSelected, - enter = fadeIn() + scaleIn(initialScale = 0.5f), - exit = fadeOut() + scaleOut(targetScale = 0.5f) + enter = fadeIn(tween(150)) + scaleIn(initialScale = 0.3f, animationSpec = spring(dampingRatio = 0.6f)), + exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) ) { Box( modifier = Modifier - .padding(end = 8.dp) - .align(Alignment.CenterVertically) + .padding(start = 12.dp, end = 4.dp) + .size(24.dp) + .clip(CircleShape) + .background(Color(0xFF4CD964)), // Зеленый как в Telegram + contentAlignment = Alignment.Center ) { Icon( - Icons.Default.CheckCircle, + Icons.Default.Check, contentDescription = "Selected", - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) + tint = Color.White, + 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( 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( indication = null, interactionSource = remember { MutableInteractionSource() },