feat: Enhance ChatDetailScreen with Telegram-style selection UI and action buttons
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user