feat: Implement message forwarding feature with chat selection and re-encryption logic
This commit is contained in:
@@ -341,7 +341,19 @@ fun MainScreen(
|
||||
currentUserPublicKey = accountPublicKey,
|
||||
currentUserPrivateKey = accountPrivateKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = { selectedUser = null }
|
||||
onBack = { selectedUser = null },
|
||||
onNavigateToChat = { publicKey ->
|
||||
// 📨 Forward: переход в выбранный чат
|
||||
// Нужно получить SearchUser из публичного ключа
|
||||
// Используем минимальные данные - остальное подгрузится в ChatDetailScreen
|
||||
selectedUser = SearchUser(
|
||||
title = "",
|
||||
username = "",
|
||||
publicKey = publicKey,
|
||||
verified = 0,
|
||||
online = 0
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
isSearchOpen -> {
|
||||
|
||||
108
app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt
Normal file
108
app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package com.rosetta.messenger.data
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 📨 Менеджер для пересылки сообщений (Forward)
|
||||
*
|
||||
* Логика как в десктопе:
|
||||
* 1. Пользователь выбирает сообщения в чате
|
||||
* 2. Нажимает Forward
|
||||
* 3. Открывается список чатов
|
||||
* 4. Выбирает чат куда переслать
|
||||
* 5. Переходит в выбранный чат с сообщениями в Reply панели (как Forward)
|
||||
*
|
||||
* Singleton для передачи данных между экранами
|
||||
*/
|
||||
object ForwardManager {
|
||||
|
||||
/**
|
||||
* Сообщение для пересылки
|
||||
*/
|
||||
data class ForwardMessage(
|
||||
val messageId: String,
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
val isOutgoing: Boolean,
|
||||
val senderPublicKey: String, // publicKey отправителя сообщения
|
||||
val originalChatPublicKey: String // publicKey чата откуда пересылается
|
||||
)
|
||||
|
||||
// Сообщения для пересылки
|
||||
private val _forwardMessages = MutableStateFlow<List<ForwardMessage>>(emptyList())
|
||||
val forwardMessages: StateFlow<List<ForwardMessage>> = _forwardMessages.asStateFlow()
|
||||
|
||||
// Флаг показа выбора чата
|
||||
private val _showChatPicker = MutableStateFlow(false)
|
||||
val showChatPicker: StateFlow<Boolean> = _showChatPicker.asStateFlow()
|
||||
|
||||
// Выбранный чат (publicKey собеседника)
|
||||
private val _selectedChatPublicKey = MutableStateFlow<String?>(null)
|
||||
val selectedChatPublicKey: StateFlow<String?> = _selectedChatPublicKey.asStateFlow()
|
||||
|
||||
/**
|
||||
* Установить сообщения для пересылки и показать выбор чата
|
||||
*/
|
||||
fun setForwardMessages(
|
||||
messages: List<ForwardMessage>,
|
||||
showPicker: Boolean = true
|
||||
) {
|
||||
android.util.Log.d("ForwardManager", "📨 Setting forward messages: ${messages.size}")
|
||||
_forwardMessages.value = messages
|
||||
if (showPicker) {
|
||||
_showChatPicker.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбрать чат для пересылки
|
||||
*/
|
||||
fun selectChat(publicKey: String) {
|
||||
android.util.Log.d("ForwardManager", "📨 Selected chat: $publicKey")
|
||||
_selectedChatPublicKey.value = publicKey
|
||||
_showChatPicker.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрыть выбор чата (отмена)
|
||||
*/
|
||||
fun hideChatPicker() {
|
||||
android.util.Log.d("ForwardManager", "📨 Hide chat picker")
|
||||
_showChatPicker.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить сообщения и очистить состояние
|
||||
* Вызывается при открытии выбранного чата
|
||||
*/
|
||||
fun consumeForwardMessages(): List<ForwardMessage> {
|
||||
val messages = _forwardMessages.value
|
||||
android.util.Log.d("ForwardManager", "📨 Consuming forward messages: ${messages.size}")
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить все данные (после применения или отмены)
|
||||
*/
|
||||
fun clear() {
|
||||
android.util.Log.d("ForwardManager", "📨 Clearing forward state")
|
||||
_forwardMessages.value = emptyList()
|
||||
_showChatPicker.value = false
|
||||
_selectedChatPublicKey.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить есть ли сообщения для пересылки
|
||||
*/
|
||||
fun hasForwardMessages(): Boolean = _forwardMessages.value.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Проверить есть ли сообщения для конкретного чата
|
||||
*/
|
||||
fun hasForwardMessagesForChat(publicKey: String): Boolean {
|
||||
val selectedKey = _selectedChatPublicKey.value
|
||||
return selectedKey == publicKey && _forwardMessages.value.isNotEmpty()
|
||||
}
|
||||
}
|
||||
@@ -600,8 +600,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Сериализация attachments в JSON с расшифровкой MESSAGES blob
|
||||
* Для MESSAGES типа blob расшифровывается и сохраняется в preview (как в RN)
|
||||
* Сериализация attachments в JSON с RE-ENCRYPTION для хранения в БД
|
||||
* Для MESSAGES типа:
|
||||
* 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
* 2. Re-encrypt с приватным ключом (как в Desktop Архиве)
|
||||
* 3. Сохраняем зашифрованный blob в БД
|
||||
*/
|
||||
private fun serializeAttachmentsWithDecryption(
|
||||
attachments: List<MessageAttachment>,
|
||||
@@ -614,9 +617,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
for (attachment in attachments) {
|
||||
val jsonObj = JSONObject()
|
||||
|
||||
// Для MESSAGES типа расшифровываем blob
|
||||
// Для MESSAGES типа расшифровываем и re-encrypt
|
||||
if (attachment.type == AttachmentType.MESSAGES && attachment.blob.isNotEmpty()) {
|
||||
try {
|
||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||
val decryptedBlob = MessageCrypto.decryptAttachmentBlob(
|
||||
attachment.blob,
|
||||
encryptedKey,
|
||||
@@ -624,11 +628,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
|
||||
if (decryptedBlob != null) {
|
||||
// Сохраняем расшифрованный JSON в preview (как в RN)
|
||||
// 2. Re-encrypt с приватным ключом для хранения (как в Desktop Архиве)
|
||||
val reEncryptedBlob = CryptoManager.encryptWithPassword(decryptedBlob, privateKey)
|
||||
|
||||
// 3. Сохраняем ЗАШИФРОВАННЫЙ blob в БД
|
||||
jsonObj.put("id", attachment.id)
|
||||
jsonObj.put("blob", decryptedBlob) // Расшифрованный JSON
|
||||
jsonObj.put("blob", reEncryptedBlob) // 🔒 Зашифрован приватным ключом!
|
||||
jsonObj.put("type", attachment.type.value)
|
||||
jsonObj.put("preview", decryptedBlob) // Для совместимости
|
||||
jsonObj.put("preview", attachment.preview)
|
||||
jsonObj.put("width", attachment.width)
|
||||
jsonObj.put("height", attachment.height)
|
||||
} else {
|
||||
|
||||
@@ -79,6 +79,7 @@ import android.content.Context
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -219,6 +220,7 @@ fun ChatDetailScreen(
|
||||
isDarkTheme: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onUserProfileClick: () -> Unit = {},
|
||||
onNavigateToChat: (String) -> Unit = {}, // 📨 Callback для навигации в другой чат (Forward)
|
||||
viewModel: ChatViewModel = viewModel()
|
||||
) {
|
||||
// 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat
|
||||
@@ -359,6 +361,20 @@ fun ChatDetailScreen(
|
||||
|
||||
// Состояние показа логов
|
||||
var showLogs by remember { mutableStateOf(false) }
|
||||
|
||||
// 📨 Forward: показывать ли выбор чата
|
||||
var showForwardPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// 📨 Forward: список диалогов для выбора (загружаем из базы)
|
||||
val chatsListViewModel: ChatsListViewModel = viewModel()
|
||||
val dialogsList by chatsListViewModel.dialogs.collectAsState()
|
||||
|
||||
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
|
||||
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
|
||||
if (currentUserPublicKey.isNotEmpty() && currentUserPrivateKey.isNotEmpty()) {
|
||||
chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey)
|
||||
}
|
||||
}
|
||||
// 🚀 Собираем логи ТОЛЬКО когда они показываются - иначе каждый лог вызывает перекомпозицию!
|
||||
val debugLogs = if (showLogs) {
|
||||
com.rosetta.messenger.network.ProtocolManager.debugLogs.collectAsState().value
|
||||
@@ -1099,7 +1115,7 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Forward button
|
||||
// Forward button - открывает выбор чата (как в десктопе)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -1107,11 +1123,24 @@ fun ChatDetailScreen(
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(PrimaryBlue.copy(alpha = 0.1f))
|
||||
.clickable {
|
||||
// 📨 Сохраняем сообщения в ForwardManager и показываем выбор чата
|
||||
val selectedMsgs = messages
|
||||
.filter { selectedMessages.contains("${it.id}_${it.timestamp.time}") }
|
||||
.sortedBy { it.timestamp }
|
||||
viewModel.setForwardMessages(selectedMsgs)
|
||||
|
||||
val forwardMessages = selectedMsgs.map { msg ->
|
||||
ForwardManager.ForwardMessage(
|
||||
messageId = msg.id,
|
||||
text = msg.text,
|
||||
timestamp = msg.timestamp.time,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
senderPublicKey = if (msg.isOutgoing) currentUserPublicKey else user.publicKey,
|
||||
originalChatPublicKey = user.publicKey
|
||||
)
|
||||
}
|
||||
ForwardManager.setForwardMessages(forwardMessages, showPicker = false)
|
||||
selectedMessages = emptySet()
|
||||
showForwardPicker = true
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -1580,6 +1609,25 @@ fun ChatDetailScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 📨 Forward Chat Picker BottomSheet
|
||||
if (showForwardPicker) {
|
||||
ForwardChatPickerBottomSheet(
|
||||
dialogs = dialogsList,
|
||||
isDarkTheme = isDarkTheme,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
onDismiss = {
|
||||
showForwardPicker = false
|
||||
ForwardManager.clear()
|
||||
},
|
||||
onChatSelected = { selectedPublicKey ->
|
||||
showForwardPicker = false
|
||||
// Переходим в выбранный чат
|
||||
ForwardManager.selectChat(selectedPublicKey)
|
||||
onNavigateToChat(selectedPublicKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 🚀 Анимация появления сообщения Telegram-style */
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.database.DialogEntity
|
||||
import com.rosetta.messenger.database.MessageEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
@@ -365,6 +366,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
readReceiptSentForCurrentDialog = false
|
||||
isDialogActive = true // 🔥 Диалог активен!
|
||||
|
||||
// 📨 Проверяем ForwardManager - если есть сообщения для пересылки в этот чат
|
||||
if (ForwardManager.hasForwardMessagesForChat(publicKey)) {
|
||||
val forwardMessages = ForwardManager.consumeForwardMessages()
|
||||
if (forwardMessages.isNotEmpty()) {
|
||||
android.util.Log.d("ChatViewModel", "📨 Received ${forwardMessages.size} forward messages")
|
||||
// Конвертируем ForwardMessage в ReplyMessage
|
||||
_replyMessages.value = forwardMessages.map { fm ->
|
||||
ReplyMessage(
|
||||
messageId = fm.messageId,
|
||||
text = fm.text,
|
||||
timestamp = fm.timestamp,
|
||||
isOutgoing = fm.isOutgoing,
|
||||
publicKey = fm.senderPublicKey
|
||||
)
|
||||
}
|
||||
_isForwardMode.value = true
|
||||
// Очищаем ForwardManager после применения
|
||||
ForwardManager.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Подписываемся на онлайн статус
|
||||
subscribeToOnlineStatus()
|
||||
@@ -716,12 +737,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (dataJson.isEmpty()) continue
|
||||
|
||||
// 🔥 Проверяем формат blob - если содержит ":", то это зашифрованный формат "iv:ciphertext"
|
||||
// Это старые сообщения (полученные до фикса) которые нельзя расшифровать
|
||||
// Расшифровываем с приватным ключом (как в Desktop Архиве)
|
||||
if (dataJson.contains(":") && dataJson.split(":").size == 2) {
|
||||
android.util.Log.d("ReplyDebug", " - Blob is encrypted (old format), skipping...")
|
||||
android.util.Log.d("ReplyDebug", " - Cannot decrypt old reply messages - they were saved before fix")
|
||||
// Пропускаем старые зашифрованные сообщения
|
||||
continue
|
||||
android.util.Log.d("ReplyDebug", " - Blob is encrypted, decrypting with private key...")
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null) {
|
||||
try {
|
||||
dataJson = CryptoManager.decryptWithPassword(dataJson, privateKey) ?: dataJson
|
||||
android.util.Log.d("ReplyDebug", " - Decrypted successfully, length: ${dataJson.length}")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ReplyDebug", " - Failed to decrypt blob", e)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("ReplyDebug", " - Cannot decrypt: private key is null")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
val messagesArray = JSONArray(dataJson)
|
||||
@@ -939,7 +970,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🔥 Формируем attachments с reply (как в React Native)
|
||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||
var replyBlobPlaintext = "" // Сохраняем plaintext для БД
|
||||
var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом)
|
||||
|
||||
if (replyMsgsToSend.isNotEmpty()) {
|
||||
android.util.Log.d("ReplyDebug", "📤 [SEND] Creating reply attachment:")
|
||||
@@ -959,16 +990,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
replyJsonArray.put(replyJson)
|
||||
}
|
||||
|
||||
replyBlobPlaintext = replyJsonArray.toString() // 🔥 Сохраняем plaintext
|
||||
val replyBlobPlaintext = replyJsonArray.toString()
|
||||
android.util.Log.d("ReplyDebug", " - Reply blob plaintext length: ${replyBlobPlaintext.length}")
|
||||
android.util.Log.d("ReplyDebug", " - Reply blob preview: ${replyBlobPlaintext.take(100)}")
|
||||
android.util.Log.d("ReplyDebug", " - Encrypting reply blob with plainKeyAndNonce (${plainKeyAndNonce.size} bytes)")
|
||||
|
||||
// 🔥 Шифруем reply blob (для network transmission)
|
||||
// 🔥 Шифруем reply blob (для network transmission) с ChaCha ключом
|
||||
android.util.Log.d("ReplyDebug", " - Encrypting reply blob with plainKeyAndNonce (${plainKeyAndNonce.size} bytes)")
|
||||
val encryptedReplyBlob = MessageCrypto.encryptReplyBlob(replyBlobPlaintext, plainKeyAndNonce)
|
||||
android.util.Log.d("ReplyDebug", " - Encrypted blob length: ${encryptedReplyBlob.length}")
|
||||
android.util.Log.d("ReplyDebug", " - Encrypted blob preview: ${encryptedReplyBlob.take(100)}")
|
||||
|
||||
// 🔥 Re-encrypt с приватным ключом для хранения в БД (как в Desktop Архиве)
|
||||
replyBlobForDatabase = CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
android.util.Log.d("ReplyDebug", " - Re-encrypted for DB length: ${replyBlobForDatabase.length}")
|
||||
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
messageAttachments.add(MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
@@ -1012,7 +1047,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
// 4. 💾 Сохранение в БД с attachments (plaintext blob для MESSAGES)
|
||||
// 4. 💾 Сохранение в БД с attachments (зашифрованный blob для MESSAGES)
|
||||
val attachmentsJson = if (messageAttachments.isNotEmpty()) {
|
||||
JSONArray().apply {
|
||||
messageAttachments.forEach { att ->
|
||||
@@ -1020,8 +1055,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
put("id", att.id)
|
||||
put("type", att.type.value)
|
||||
put("preview", att.preview)
|
||||
// 🔥 Для MESSAGES сохраняем plaintext, для остальных - как есть
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) replyBlobPlaintext else att.blob)
|
||||
// 🔥 Для MESSAGES сохраняем зашифрованный приватным ключом, для остальных - как есть
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) replyBlobForDatabase else att.blob)
|
||||
})
|
||||
}
|
||||
}.toString()
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Forward
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* 📨 BottomSheet для выбора чата при Forward сообщений
|
||||
*
|
||||
* Логика как в десктопной версии:
|
||||
* 1. Показывает список диалогов
|
||||
* 2. При выборе диалога - переходит в чат с сообщениями в Reply панели
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ForwardChatPickerBottomSheet(
|
||||
dialogs: List<DialogUiModel>,
|
||||
isDarkTheme: Boolean,
|
||||
currentUserPublicKey: String,
|
||||
onDismiss: () -> Unit,
|
||||
onChatSelected: (String) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = false
|
||||
)
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||
|
||||
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
|
||||
val messagesCount = forwardMessages.size
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = backgroundColor,
|
||||
dragHandle = {
|
||||
// Кастомный handle
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(36.dp)
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(2.5.dp))
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6)
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Иконка и заголовок
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Forward,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Forward to",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
Text(
|
||||
text = "$messagesCount message${if (messagesCount > 1) "s" else ""} selected",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка закрытия
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
// Список диалогов
|
||||
if (dialogs.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "No chats yet",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Start a conversation first",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
) {
|
||||
items(dialogs, key = { it.opponentKey }) { dialog ->
|
||||
ForwardDialogItem(
|
||||
dialog = dialog,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isSavedMessages = dialog.opponentKey == currentUserPublicKey,
|
||||
onClick = {
|
||||
onChatSelected(dialog.opponentKey)
|
||||
}
|
||||
)
|
||||
|
||||
// Сепаратор между диалогами
|
||||
if (dialog != dialogs.last()) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 76.dp),
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Нижний padding
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент диалога в списке выбора для Forward
|
||||
*/
|
||||
@Composable
|
||||
private fun ForwardDialogItem(
|
||||
dialog: DialogUiModel,
|
||||
isDarkTheme: Boolean,
|
||||
isSavedMessages: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
|
||||
val avatarColors = remember(dialog.opponentKey, isDarkTheme) {
|
||||
getAvatarColor(dialog.opponentKey, isDarkTheme)
|
||||
}
|
||||
|
||||
val displayName = remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) {
|
||||
when {
|
||||
isSavedMessages -> "Saved Messages"
|
||||
dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle
|
||||
else -> dialog.opponentKey.take(8)
|
||||
}
|
||||
}
|
||||
|
||||
val initials = remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) {
|
||||
when {
|
||||
isSavedMessages -> "📁"
|
||||
dialog.opponentTitle.isNotEmpty() -> {
|
||||
dialog.opponentTitle
|
||||
.split(" ")
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercase() }
|
||||
.joinToString("")
|
||||
}
|
||||
else -> dialog.opponentKey.take(2).uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f)
|
||||
else avatarColors.backgroundColor
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Text(
|
||||
text = if (isSavedMessages) "Your personal notes" else dialog.lastMessage.ifEmpty { "No messages" },
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Online indicator
|
||||
if (!isSavedMessages && dialog.isOnline == 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF34C759))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user