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 ключ (как для сообщений)
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),

View File

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