feat: Enhance chat functionality by updating dialog handling and adding ChatsListViewModel for database integration
This commit is contained in:
@@ -274,83 +274,89 @@ object MessageCrypto {
|
||||
/**
|
||||
* ECDH шифрование ключа для получателя
|
||||
* Использует secp256k1 + AES как в RN версии
|
||||
* Формат: Base64(iv:ciphertext:ephemeralPrivateKeyHex)
|
||||
*/
|
||||
fun encryptKeyForRecipient(keyAndNonce: ByteArray, recipientPublicKeyHex: String): String {
|
||||
val secureRandom = SecureRandom()
|
||||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||
|
||||
// Генерируем эфемерный приватный ключ
|
||||
// Генерируем эфемерный приватный ключ (32 байта)
|
||||
val ephemeralPrivateKeyBytes = ByteArray(32)
|
||||
secureRandom.nextBytes(ephemeralPrivateKeyBytes)
|
||||
val ephemeralPrivateKey = BigInteger(1, ephemeralPrivateKeyBytes)
|
||||
val ephemeralPrivateKeyHex = ephemeralPrivateKeyBytes.toHex()
|
||||
|
||||
// Получаем эфемерный публичный ключ
|
||||
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
|
||||
val ephemeralPublicKeyHex = ephemeralPublicKey.getEncoded(false).toHex()
|
||||
|
||||
// Парсим публичный ключ получателя
|
||||
val recipientPublicKeyBytes = recipientPublicKeyHex.hexToBytes()
|
||||
val recipientPublicKey = ecSpec.curve.decodePoint(recipientPublicKeyBytes)
|
||||
|
||||
// ECDH: получаем общий секрет
|
||||
// ECDH: ephemeralPrivate * recipientPublic = sharedSecret
|
||||
val sharedPoint = recipientPublicKey.multiply(ephemeralPrivateKey)
|
||||
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||||
val sharedSecretHex = sharedSecret.toHex()
|
||||
|
||||
// Derive AES key from shared secret
|
||||
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
||||
|
||||
// Генерируем IV для AES
|
||||
// Генерируем IV для AES (16 байт)
|
||||
val iv = ByteArray(16)
|
||||
secureRandom.nextBytes(iv)
|
||||
val ivHex = iv.toHex()
|
||||
|
||||
// Шифруем keyAndNonce с AES-CBC
|
||||
val secretKey = SecretKeySpec(aesKey, "AES")
|
||||
// Шифруем keyAndNonce с AES-CBC используя sharedSecret как ключ
|
||||
// ВАЖНО: Используем сам sharedSecret напрямую (32 байта) без SHA-256!
|
||||
val aesKey = SecretKeySpec(sharedSecret, "AES")
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||
val encryptedKey = cipher.doFinal(keyAndNonce)
|
||||
val encryptedKeyHex = encryptedKey.toHex()
|
||||
|
||||
// Формат: iv:ciphertext:ephemeralPublicKey (Base64)
|
||||
val result = ByteArray(iv.size + encryptedKey.size + recipientPublicKeyBytes.size)
|
||||
System.arraycopy(iv, 0, result, 0, iv.size)
|
||||
System.arraycopy(encryptedKey, 0, result, iv.size, encryptedKey.size)
|
||||
// Формат как в RN: btoa(ivHex:encryptedHex:ephemeralPrivateHex)
|
||||
val combined = "$ivHex:$encryptedKeyHex:$ephemeralPrivateKeyHex"
|
||||
|
||||
// Возвращаем как Base64 с ephemeral public key в конце
|
||||
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" +
|
||||
Base64.encodeToString(encryptedKey, Base64.NO_WRAP) + ":" +
|
||||
ephemeralPublicKeyHex
|
||||
ProtocolManager.addLog("🔐 ECDH Encrypt:")
|
||||
ProtocolManager.addLog(" - Shared secret: ${sharedSecretHex.take(40)}...")
|
||||
ProtocolManager.addLog(" - IV: ${ivHex.take(32)}...")
|
||||
ProtocolManager.addLog(" - Ephemeral private: ${ephemeralPrivateKeyHex.take(40)}...")
|
||||
ProtocolManager.addLog(" - Recipient public: ${recipientPublicKeyHex.take(40)}...")
|
||||
|
||||
return Base64.encodeToString(combined.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* ECDH расшифровка ключа
|
||||
* Формат: Base64(ivHex:encryptedHex:ephemeralPrivateHex)
|
||||
*/
|
||||
fun decryptKeyFromSender(encryptedKeyBase64: String, myPrivateKeyHex: String): ByteArray {
|
||||
val parts = encryptedKeyBase64.split(":")
|
||||
if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format")
|
||||
val combined = String(Base64.decode(encryptedKeyBase64, Base64.NO_WRAP))
|
||||
val parts = combined.split(":")
|
||||
if (parts.size != 3) throw IllegalArgumentException("Invalid encrypted key format: expected 3 parts, got ${parts.size}")
|
||||
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val encryptedKey = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val ephemeralPublicKeyHex = parts[2]
|
||||
val ivHex = parts[0]
|
||||
val encryptedKeyHex = parts[1]
|
||||
val ephemeralPrivateKeyHex = parts[2]
|
||||
|
||||
val iv = ivHex.hexToBytes()
|
||||
val encryptedKey = encryptedKeyHex.hexToBytes()
|
||||
|
||||
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
|
||||
|
||||
// Парсим эфемерный приватный ключ
|
||||
val ephemeralPrivateKey = BigInteger(ephemeralPrivateKeyHex, 16)
|
||||
val ephemeralPublicKey = ecSpec.g.multiply(ephemeralPrivateKey)
|
||||
|
||||
// Парсим мой приватный ключ
|
||||
val myPrivateKey = BigInteger(myPrivateKeyHex, 16)
|
||||
val myPublicKey = ecSpec.g.multiply(myPrivateKey)
|
||||
|
||||
// Парсим эфемерный публичный ключ отправителя
|
||||
val ephemeralPublicKeyBytes = ephemeralPublicKeyHex.hexToBytes()
|
||||
val ephemeralPublicKey = ecSpec.curve.decodePoint(ephemeralPublicKeyBytes)
|
||||
|
||||
// ECDH: получаем общий секрет
|
||||
val sharedPoint = ephemeralPublicKey.multiply(myPrivateKey)
|
||||
// ECDH: ephemeralPrivate * myPublic = sharedSecret
|
||||
val sharedPoint = myPublicKey.multiply(ephemeralPrivateKey)
|
||||
val sharedSecret = sharedPoint.normalize().xCoord.encoded
|
||||
|
||||
// Derive AES key from shared secret
|
||||
val aesKey = MessageDigest.getInstance("SHA-256").digest(sharedSecret)
|
||||
|
||||
// Расшифровываем
|
||||
val secretKey = SecretKeySpec(aesKey, "AES")
|
||||
// Расшифровываем используя sharedSecret как AES ключ
|
||||
val aesKey = SecretKeySpec(sharedSecret, "AES")
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
||||
|
||||
return cipher.doFinal(encryptedKey)
|
||||
}
|
||||
@@ -359,15 +365,31 @@ object MessageCrypto {
|
||||
* Полное шифрование сообщения для отправки
|
||||
*/
|
||||
fun encryptForSending(plaintext: String, recipientPublicKey: String): Pair<String, String> {
|
||||
android.util.Log.d("MessageCrypto", "🔐 === START ENCRYPTION ===")
|
||||
android.util.Log.d("MessageCrypto", "📝 Plaintext: '$plaintext'")
|
||||
android.util.Log.d("MessageCrypto", "📝 Plaintext length: ${plaintext.length}")
|
||||
android.util.Log.d("MessageCrypto", "🔑 Recipient public key: ${recipientPublicKey.take(20)}...")
|
||||
|
||||
// 1. Шифруем текст
|
||||
android.util.Log.d("MessageCrypto", "⚡ Step 1: Encrypting message with XChaCha20-Poly1305...")
|
||||
val encrypted = encryptMessage(plaintext)
|
||||
android.util.Log.d("MessageCrypto", "✅ Ciphertext: ${encrypted.ciphertext.take(40)}...")
|
||||
android.util.Log.d("MessageCrypto", "✅ Ciphertext length: ${encrypted.ciphertext.length}")
|
||||
android.util.Log.d("MessageCrypto", "✅ Key: ${encrypted.key.take(20)}...")
|
||||
android.util.Log.d("MessageCrypto", "✅ Nonce: ${encrypted.nonce.take(20)}...")
|
||||
|
||||
// 2. Собираем key + nonce
|
||||
android.util.Log.d("MessageCrypto", "⚡ Step 2: Combining key + nonce...")
|
||||
val keyAndNonce = encrypted.key.hexToBytes() + encrypted.nonce.hexToBytes()
|
||||
android.util.Log.d("MessageCrypto", "✅ Combined keyAndNonce length: ${keyAndNonce.size} bytes")
|
||||
|
||||
// 3. Шифруем ключ для получателя
|
||||
android.util.Log.d("MessageCrypto", "⚡ Step 3: Encrypting key with ECDH + AES...")
|
||||
val encryptedKey = encryptKeyForRecipient(keyAndNonce, recipientPublicKey)
|
||||
android.util.Log.d("MessageCrypto", "✅ Encrypted key: ${encryptedKey.take(40)}...")
|
||||
android.util.Log.d("MessageCrypto", "✅ Encrypted key length: ${encryptedKey.length}")
|
||||
|
||||
android.util.Log.d("MessageCrypto", "🔐 === ENCRYPTION COMPLETE ===")
|
||||
return Pair(encrypted.ciphertext, encryptedKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ private fun Message.toChatMessage() = ChatMessage(
|
||||
/**
|
||||
* Экран детального чата с пользователем
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ChatDetailScreen(
|
||||
user: SearchUser,
|
||||
@@ -97,6 +97,7 @@ fun ChatDetailScreen(
|
||||
onUserProfileClick: () -> Unit = {},
|
||||
viewModel: ChatViewModel = viewModel()
|
||||
) {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
@@ -122,7 +123,7 @@ fun ChatDetailScreen(
|
||||
// Инициализируем ViewModel с ключами и открываем диалог
|
||||
LaunchedEffect(user.publicKey) {
|
||||
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||
viewModel.openDialog(user.publicKey)
|
||||
viewModel.openDialog(user.publicKey, user.title, user.username)
|
||||
}
|
||||
|
||||
// Прокрутка при новых сообщениях
|
||||
@@ -154,7 +155,10 @@ fun ChatDetailScreen(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Кнопка назад
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = {
|
||||
keyboardController?.hide()
|
||||
onBack()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
@@ -545,7 +549,7 @@ private fun MessageInputBar(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.then(if (!showEmojiPicker) Modifier.imePadding() else Modifier)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -709,8 +713,8 @@ private fun MessageInputBar(
|
||||
// Apple Emoji Picker
|
||||
AnimatedVisibility(
|
||||
visible = showEmojiPicker,
|
||||
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
|
||||
enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(),
|
||||
exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut()
|
||||
) {
|
||||
AppleEmojiPickerPanel(
|
||||
isDarkTheme = isDarkTheme,
|
||||
|
||||
@@ -6,6 +6,8 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.database.DialogEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -22,6 +24,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val TAG = "ChatViewModel"
|
||||
}
|
||||
|
||||
// Database
|
||||
private val database = RosettaDatabase.getDatabase(application)
|
||||
private val dialogDao = database.dialogDao()
|
||||
|
||||
// Информация о собеседнике
|
||||
private var opponentTitle: String = ""
|
||||
private var opponentUsername: String = ""
|
||||
|
||||
// Текущий диалог
|
||||
private var opponentKey: String? = null
|
||||
private var myPublicKey: String? = null
|
||||
@@ -120,14 +130,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/**
|
||||
* Открыть диалог
|
||||
*/
|
||||
fun openDialog(publicKey: String) {
|
||||
fun openDialog(publicKey: String, title: String = "", username: String = "") {
|
||||
if (opponentKey == publicKey) {
|
||||
ProtocolManager.addLog("💬 Dialog already open: ${publicKey.take(16)}...")
|
||||
return
|
||||
}
|
||||
opponentKey = publicKey
|
||||
opponentTitle = title
|
||||
opponentUsername = username
|
||||
_messages.value = emptyList()
|
||||
ProtocolManager.addLog("💬 Dialog opened: ${publicKey.take(16)}...")
|
||||
ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,15 +191,26 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
_inputText.value = ""
|
||||
|
||||
ProtocolManager.addLog("📤 Send: \"${text.take(20)}...\"")
|
||||
ProtocolManager.addLog("📋 Messages count: ${_messages.value.size}")
|
||||
ProtocolManager.addLog("📤 === START SENDING MESSAGE ===")
|
||||
ProtocolManager.addLog("📤 Text: \"${text.take(20)}...\"")
|
||||
ProtocolManager.addLog("📤 Text length: ${text.length}")
|
||||
ProtocolManager.addLog("📤 Recipient: ${recipient.take(20)}...")
|
||||
ProtocolManager.addLog("📤 Sender: ${sender.take(20)}...")
|
||||
ProtocolManager.addLog("📤 Message ID: $messageId")
|
||||
ProtocolManager.addLog("📋 Current messages count: ${_messages.value.size}")
|
||||
|
||||
// 2. Отправка в фоне
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
ProtocolManager.addLog("🔐 Encrypting...")
|
||||
ProtocolManager.addLog("🔐 Starting encryption...")
|
||||
val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient)
|
||||
ProtocolManager.addLog("✓ Encrypted")
|
||||
ProtocolManager.addLog("✅ Encryption complete")
|
||||
ProtocolManager.addLog(" - Encrypted content length: ${encryptedContent.length}")
|
||||
ProtocolManager.addLog(" - Encrypted key length: ${encryptedKey.length}")
|
||||
|
||||
ProtocolManager.addLog("📦 Creating PacketMessage...")
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
ProtocolManager.addLog("🔑 Private key hash: ${privateKeyHash.take(20)}...")
|
||||
|
||||
val packet = PacketMessage().apply {
|
||||
fromPublicKey = sender
|
||||
@@ -195,16 +218,29 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
attachments = emptyList()
|
||||
}
|
||||
|
||||
ProtocolManager.addLog("📡 Sending packet...")
|
||||
ProtocolManager.addLog("<EFBFBD> Packet created:")
|
||||
ProtocolManager.addLog(" - From: ${sender.take(20)}...")
|
||||
ProtocolManager.addLog(" - To: ${recipient.take(20)}...")
|
||||
ProtocolManager.addLog(" - Content: ${encryptedContent.take(40)}...")
|
||||
ProtocolManager.addLog(" - ChaCha Key: ${encryptedKey.take(40)}...")
|
||||
ProtocolManager.addLog(" - Timestamp: $timestamp")
|
||||
ProtocolManager.addLog(" - Message ID: $messageId")
|
||||
|
||||
ProtocolManager.addLog("📡 Sending packet to server...")
|
||||
ProtocolManager.send(packet)
|
||||
ProtocolManager.addLog("📡 Packet sent to ProtocolManager")
|
||||
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
ProtocolManager.addLog("✓ Sent!")
|
||||
ProtocolManager.addLog("✅ Message status updated to SENT")
|
||||
ProtocolManager.addLog("📤 === SENDING COMPLETE ===")
|
||||
|
||||
// Сохраняем/обновляем диалог в базе
|
||||
saveDialog(text, timestamp)
|
||||
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog("❌ Error: ${e.message}")
|
||||
@@ -215,6 +251,36 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить диалог в базу данных
|
||||
*/
|
||||
private suspend fun saveDialog(lastMessage: String, timestamp: Long) {
|
||||
val account = myPublicKey ?: return
|
||||
val opponent = opponentKey ?: return
|
||||
|
||||
try {
|
||||
val existingDialog = dialogDao.getDialog(account, opponent)
|
||||
|
||||
if (existingDialog != null) {
|
||||
// Обновляем последнее сообщение
|
||||
dialogDao.updateLastMessage(account, opponent, lastMessage, timestamp)
|
||||
} else {
|
||||
// Создаём новый диалог
|
||||
dialogDao.insertDialog(DialogEntity(
|
||||
account = account,
|
||||
opponentKey = opponent,
|
||||
opponentTitle = opponentTitle,
|
||||
opponentUsername = opponentUsername,
|
||||
lastMessage = lastMessage,
|
||||
lastMessageTimestamp = timestamp
|
||||
))
|
||||
}
|
||||
ProtocolManager.addLog("💾 Dialog saved")
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog("❌ Failed to save dialog: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun showTypingIndicator() {
|
||||
_opponentTyping.value = true
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.database.DialogEntity
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
@@ -148,6 +149,7 @@ fun ChatsListScreen(
|
||||
onSearchClick: () -> Unit,
|
||||
onNewChat: () -> Unit,
|
||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
// Theme transition state
|
||||
@@ -187,6 +189,16 @@ fun ChatsListScreen(
|
||||
val protocolState by ProtocolManager.state.collectAsState()
|
||||
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||
|
||||
// Dialogs from database
|
||||
val dialogsList by chatsViewModel.dialogs.collectAsState()
|
||||
|
||||
// Load dialogs when account is available
|
||||
LaunchedEffect(accountPublicKey) {
|
||||
if (accountPublicKey.isNotEmpty()) {
|
||||
chatsViewModel.setAccount(accountPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Dev console state
|
||||
var showDevConsole by remember { mutableStateOf(false) }
|
||||
var titleClickCount by remember { mutableStateOf(0) }
|
||||
@@ -776,12 +788,28 @@ fun ChatsListScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (dialogsList.isEmpty()) {
|
||||
// Empty state with Lottie animation
|
||||
EmptyChatsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
// Show dialogs list
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(dialogsList, key = { it.opponentKey }) { dialog ->
|
||||
DialogItem(
|
||||
dialog = dialog,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onClick = {
|
||||
val user = chatsViewModel.dialogToSearchUser(dialog)
|
||||
onUserSelect(user)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Console button - always visible at bottom left
|
||||
@@ -1096,3 +1124,138 @@ private fun formatTime(date: Date): String {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Элемент диалога из базы данных
|
||||
*/
|
||||
@Composable
|
||||
fun DialogItem(
|
||||
dialog: DialogEntity,
|
||||
isDarkTheme: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
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 avatarColors = getAvatarColor(dialog.opponentKey, isDarkTheme)
|
||||
val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
|
||||
val initials = if (dialog.opponentTitle.isNotEmpty()) {
|
||||
dialog.opponentTitle.split(" ")
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercase() }
|
||||
.joinToString("")
|
||||
} else {
|
||||
dialog.opponentKey.take(2).uppercase()
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
color = avatarColors.textColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
|
||||
// Online indicator
|
||||
if (dialog.isOnline == 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = (-2).dp, y = (-2).dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White)
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF4CAF50))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Name and last message
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = formatTime(Date(dialog.lastMessageTimestamp)),
|
||||
fontSize = 13.sp,
|
||||
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = dialog.lastMessage.ifEmpty { "No messages" },
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Unread badge
|
||||
if (dialog.unreadCount > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PrimaryBlue),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (dialog.unreadCount > 99) "99+" else dialog.unreadCount.toString(),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 84.dp),
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.database.DialogEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ViewModel для списка чатов
|
||||
* Загружает диалоги из базы данных
|
||||
*/
|
||||
class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val database = RosettaDatabase.getDatabase(application)
|
||||
private val dialogDao = database.dialogDao()
|
||||
|
||||
private var currentAccount: String = ""
|
||||
|
||||
// Список диалогов из базы
|
||||
private val _dialogs = MutableStateFlow<List<DialogEntity>>(emptyList())
|
||||
val dialogs: StateFlow<List<DialogEntity>> = _dialogs.asStateFlow()
|
||||
|
||||
// Загрузка
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
/**
|
||||
* Установить текущий аккаунт и загрузить диалоги
|
||||
*/
|
||||
fun setAccount(publicKey: String) {
|
||||
if (currentAccount == publicKey) return
|
||||
currentAccount = publicKey
|
||||
|
||||
viewModelScope.launch {
|
||||
dialogDao.getDialogsFlow(publicKey)
|
||||
.collect { dialogsList ->
|
||||
_dialogs.value = dialogsList
|
||||
ProtocolManager.addLog("📋 Dialogs loaded: ${dialogsList.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать или обновить диалог после отправки/получения сообщения
|
||||
*/
|
||||
suspend fun upsertDialog(
|
||||
opponentKey: String,
|
||||
opponentTitle: String,
|
||||
opponentUsername: String = "",
|
||||
lastMessage: String,
|
||||
timestamp: Long,
|
||||
verified: Int = 0,
|
||||
isOnline: Int = 0
|
||||
) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
|
||||
val existingDialog = dialogDao.getDialog(currentAccount, opponentKey)
|
||||
|
||||
if (existingDialog != null) {
|
||||
// Обновляем
|
||||
dialogDao.updateLastMessage(currentAccount, opponentKey, lastMessage, timestamp)
|
||||
if (opponentTitle.isNotEmpty()) {
|
||||
dialogDao.updateOpponentInfo(currentAccount, opponentKey, opponentTitle, opponentUsername, verified)
|
||||
}
|
||||
} else {
|
||||
// Создаём новый
|
||||
dialogDao.insertDialog(DialogEntity(
|
||||
account = currentAccount,
|
||||
opponentKey = opponentKey,
|
||||
opponentTitle = opponentTitle,
|
||||
opponentUsername = opponentUsername,
|
||||
lastMessage = lastMessage,
|
||||
lastMessageTimestamp = timestamp,
|
||||
verified = verified,
|
||||
isOnline = isOnline
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертировать DialogEntity в SearchUser для навигации
|
||||
*/
|
||||
fun dialogToSearchUser(dialog: DialogEntity): SearchUser {
|
||||
return SearchUser(
|
||||
title = dialog.opponentTitle,
|
||||
username = dialog.opponentUsername,
|
||||
publicKey = dialog.opponentKey,
|
||||
verified = dialog.verified,
|
||||
online = dialog.isOnline
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user