From 81d2b744ba99a43b2e12f385254507bfda523887 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 16 Jan 2026 03:29:32 +0500 Subject: [PATCH] feat: Implement message forwarding feature with chat selection and re-encryption logic --- .../com/rosetta/messenger/MainActivity.kt | 14 +- .../rosetta/messenger/data/ForwardManager.kt | 108 +++++++ .../messenger/data/MessageRepository.kt | 19 +- .../messenger/ui/chats/ChatDetailScreen.kt | 52 ++- .../messenger/ui/chats/ChatViewModel.kt | 59 +++- .../ui/chats/ForwardChatPickerBottomSheet.kt | 299 ++++++++++++++++++ 6 files changed, 530 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 42b327c..5b22aae 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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 -> { diff --git a/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt b/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt new file mode 100644 index 0000000..84653ae --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt @@ -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>(emptyList()) + val forwardMessages: StateFlow> = _forwardMessages.asStateFlow() + + // Флаг показа выбора чата + private val _showChatPicker = MutableStateFlow(false) + val showChatPicker: StateFlow = _showChatPicker.asStateFlow() + + // Выбранный чат (publicKey собеседника) + private val _selectedChatPublicKey = MutableStateFlow(null) + val selectedChatPublicKey: StateFlow = _selectedChatPublicKey.asStateFlow() + + /** + * Установить сообщения для пересылки и показать выбор чата + */ + fun setForwardMessages( + messages: List, + 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 { + 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() + } +} diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 40bdcb5..fcc988f 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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, @@ -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 { 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 14ffc66..68d905a 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 @@ -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 */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 69fc2a1..fd3973a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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() - 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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt new file mode 100644 index 0000000..6d63088 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -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, + 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)) + ) + } + } +}