feat: Enhance ChatDetailScreen with Telegram-style selection UI and action buttons

This commit is contained in:
k1ngsterr1
2026-01-14 01:39:44 +05:00
parent 3c6f1cdd2f
commit 01441e21d9
2 changed files with 114 additions and 54 deletions

View File

@@ -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),

View File

@@ -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() },