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 15a18bf..ba49184 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 @@ -379,6 +379,35 @@ fun ChatDetailScreen( val hasReply = replyMessages.isNotEmpty() val isForwardMode by viewModel.isForwardMode.collectAsState() + // Avatar-сообщения не должны попадать в selection ни при каких условиях. + val avatarMessageIds = + remember(messages) { + messages.filter { msg -> + msg.attachments.any { it.type == AttachmentType.AVATAR } + }.map { it.id }.toSet() + } + + LaunchedEffect(avatarMessageIds) { + if (selectedMessages.isNotEmpty()) { + val filteredSelection = selectedMessages - avatarMessageIds + if (filteredSelection != selectedMessages) { + selectedMessages = filteredSelection + } + } + } + + val toggleMessageSelection: (messageId: String, canSelect: Boolean) -> Unit = + { messageId, canSelect -> + if (canSelect) { + selectedMessages = + if (selectedMessages.contains(messageId)) { + selectedMessages - messageId + } else { + selectedMessages + messageId + } + } + } + // 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх // NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true // автоматически сохраняет позицию благодаря стабильным ключам (key = message.id) @@ -661,9 +690,10 @@ fun ChatDetailScreen( messages .filter { selectedMessages - .contains( - it.id - ) + .contains(it.id) && + it.attachments.none { attachment -> + attachment.type == AttachmentType.AVATAR + } } .sortedWith( compareBy( @@ -718,9 +748,10 @@ fun ChatDetailScreen( messages .filter { selectedMessages - .contains( - it.id - ) + .contains(it.id) && + it.attachments.none { attachment -> + attachment.type == AttachmentType.AVATAR + } } .forEach { msg @@ -1216,8 +1247,9 @@ fun ChatDetailScreen( label = "bottomBarContent" ) { selectionMode -> if (selectionMode) { - // SELECTION ACTION BAR - Reply/Forward - Column( + if (!isSystemAccount) { + // SELECTION ACTION BAR - Reply/Forward + Column( modifier = Modifier.fillMaxWidth() .background( @@ -1320,10 +1352,11 @@ fun ChatDetailScreen( val selectedMsgs = messages .filter { - selectedMessages - .contains( - it.id - ) + selectedMessages + .contains(it.id) && + it.attachments.none { attachment -> + attachment.type == AttachmentType.AVATAR + } } .sortedWith( compareBy( @@ -1406,10 +1439,11 @@ fun ChatDetailScreen( val selectedMsgs = messages .filter { - selectedMessages - .contains( - it.id - ) + selectedMessages + .contains(it.id) && + it.attachments.none { attachment -> + attachment.type == AttachmentType.AVATAR + } } .sortedWith( compareBy( @@ -1556,6 +1590,7 @@ fun ChatDetailScreen( } } } + } } else if (!isSystemAccount) { // INPUT BAR Column { @@ -1972,18 +2007,10 @@ fun ChatDetailScreen( showEmojiPicker = false } - selectedMessages = - if (selectedMessages - .contains( - selectionKey - ) - ) { - selectedMessages - - selectionKey - } else { - selectedMessages + - selectionKey - } + toggleMessageSelection( + selectionKey, + true + ) }, onClick = { val hasAvatar = @@ -1993,20 +2020,11 @@ fun ChatDetailScreen( AttachmentType .AVATAR } - if (isSelectionMode && !hasAvatar - ) { - selectedMessages = - if (selectedMessages - .contains( - selectionKey - ) - ) { - selectedMessages - - selectionKey - } else { - selectedMessages + - selectionKey - } + if (isSelectionMode) { + toggleMessageSelection( + selectionKey, + !hasAvatar + ) } }, onSwipeToReply = { @@ -2018,7 +2036,8 @@ fun ChatDetailScreen( AttachmentType .AVATAR } - if (!hasAvatar + if (!hasAvatar && + !isSystemAccount ) { viewModel .setReplyMessages( @@ -2364,33 +2383,37 @@ fun ChatDetailScreen( }, onChatsSelected = { selectedDialogs -> showForwardPicker = false - if (selectedDialogs.isNotEmpty()) { - val primaryDialog = selectedDialogs.first() - val additionalDialogs = selectedDialogs.drop(1) + if (selectedDialogs.isEmpty()) { + ForwardManager.clear() + return@ForwardChatPickerBottomSheet + } - // Отправляем forward напрямую в дополнительные чаты - val fwdMessages = ForwardManager.consumeForwardMessages() - if (additionalDialogs.isNotEmpty() && fwdMessages.isNotEmpty()) { - additionalDialogs.forEach { dialog -> - viewModel.sendForwardDirectly( - dialog.opponentKey, - fwdMessages - ) - } - } + val forwardMessages = ForwardManager.consumeForwardMessages() + ForwardManager.clear() + if (forwardMessages.isEmpty()) { + return@ForwardChatPickerBottomSheet + } - // Навигируемся в первый выбранный чат с forward - ForwardManager.setForwardMessages(fwdMessages, showPicker = false) - ForwardManager.selectChat(primaryDialog.opponentKey) - val searchUser = - SearchUser( - title = primaryDialog.opponentTitle, - username = primaryDialog.opponentUsername, - publicKey = primaryDialog.opponentKey, - verified = primaryDialog.verified, - online = primaryDialog.isOnline - ) - onNavigateToChat(searchUser) + // Реальная отправка forward во все выбранные чаты. + selectedDialogs.forEach { dialog -> + viewModel.sendForwardDirectly( + dialog.opponentKey, + forwardMessages + ) + } + + // Открываем первый выбранный чат (кроме текущего), чтобы сразу увидеть результат. + val primaryDialog = selectedDialogs.first() + if (primaryDialog.opponentKey != user.publicKey) { + val searchUser = + SearchUser( + title = primaryDialog.opponentTitle, + username = primaryDialog.opponentUsername, + publicKey = primaryDialog.opponentKey, + verified = primaryDialog.verified, + online = primaryDialog.isOnline + ) + onNavigateToChat(searchUser) } } ) 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 6fca105..b0754f5 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 @@ -2210,23 +2210,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { timestamp = timestamp, isFromMe = true, delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = attachmentsJson + attachmentsJson = attachmentsJson, + opponentPublicKey = recipientPublicKey ) - // Сохраняем диалог (для списка чатов) + // Обновляем диалог (для списка чатов) из таблицы сообщений. val db = RosettaDatabase.getDatabase(context) val dialogDao = db.dialogDao() - val dialogKey = if (sender < recipientPublicKey) "$sender:$recipientPublicKey" - else "$recipientPublicKey:$sender" - val existingDialog = dialogDao.getDialog(sender, recipientPublicKey) - val encryptedLastMsg = CryptoManager.encryptWithPassword("Forwarded message", privateKey) - if (existingDialog != null) { - dialogDao.updateLastMessage( - sender, - recipientPublicKey, - encryptedLastMsg, - timestamp - ) + if (isSavedMessages) { + dialogDao.updateSavedMessagesDialogFromMessages(sender) + } else { + dialogDao.updateDialogFromMessages(sender, recipientPublicKey) } } catch (e: Exception) { } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 08ad54d..4b71452 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -181,25 +181,13 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio loadUserInfoForDialog(dialog.opponentKey) } - // 🚀 Расшифровка теперь кэшируется в CryptoManager! + // Безопасная дешифровка превью: никогда не показываем raw ciphertext. val decryptedLastMessage = - try { - if (privateKey.isNotEmpty() && - dialog.lastMessage - .isNotEmpty() - ) { - CryptoManager.decryptWithPassword( + decryptDialogPreview( + encryptedLastMessage = dialog.lastMessage, - privateKey - ) - ?: dialog.lastMessage - } else { - dialog.lastMessage - } - } catch (e: Exception) { - dialog.lastMessage // Fallback на - // зашифрованный текст - } + privateKey = privateKey + ) // � ОПТИМИЗАЦИЯ: Используем денормализованные поля из // DialogEntity @@ -211,52 +199,12 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 📎 Определяем тип attachment из кэшированного поля в // DialogEntity val attachmentType = - try { - val attachmentsJson = - dialog.lastMessageAttachments - if (attachmentsJson.isNotEmpty() && - attachmentsJson != "[]" - ) { - val attachments = - org.json.JSONArray( - attachmentsJson - ) - if (attachments.length() > 0) { - val firstAttachment = - attachments.getJSONObject(0) - val type = - firstAttachment.optInt( - "type", - -1 - ) - when (type) { - 0 -> - "Photo" // AttachmentType.IMAGE = 0 - 1 -> { - // AttachmentType.MESSAGES = - // 1 (Reply или Forward) - // Reply: есть текст - // сообщения -> показываем - // текст (null) - // Forward: текст пустой -> - // показываем "Forwarded" - if (decryptedLastMessage - .isNotEmpty() - ) - null - else "Forwarded" - } - 2 -> - "File" // AttachmentType.FILE = 2 - 3 -> - "Avatar" // AttachmentType.AVATAR = 3 - else -> null - } - } else null - } else null - } catch (e: Exception) { - null - } + resolveAttachmentType( + attachmentsJson = + dialog.lastMessageAttachments, + decryptedLastMessage = + decryptedLastMessage + ) DialogUiModel( id = dialog.id, @@ -336,74 +284,23 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio loadUserInfoForRequest(dialog.opponentKey) } - // 🚀 Расшифровка теперь кэшируется в CryptoManager! + // Безопасная дешифровка превью: никогда не показываем raw ciphertext. val decryptedLastMessage = - try { - if (privateKey.isNotEmpty() && - dialog.lastMessage - .isNotEmpty() - ) { - CryptoManager.decryptWithPassword( + decryptDialogPreview( + encryptedLastMessage = dialog.lastMessage, - privateKey - ) - ?: dialog.lastMessage - } else { - dialog.lastMessage - } - } catch (e: Exception) { - dialog.lastMessage - } + privateKey = privateKey + ) // 📎 Определяем тип attachment из кэшированного поля в // DialogEntity val attachmentType = - try { - val attachmentsJson = - dialog.lastMessageAttachments - if (attachmentsJson.isNotEmpty() && - attachmentsJson != "[]" - ) { - val attachments = - org.json.JSONArray( - attachmentsJson - ) - if (attachments.length() > 0) { - val firstAttachment = - attachments.getJSONObject(0) - val type = - firstAttachment.optInt( - "type", - -1 - ) - when (type) { - 0 -> - "Photo" // AttachmentType.IMAGE = 0 - 1 -> { - // AttachmentType.MESSAGES = - // 1 (Reply или Forward) - // Reply: есть текст - // сообщения -> показываем - // текст (null) - // Forward: текст пустой -> - // показываем "Forwarded" - if (decryptedLastMessage - .isNotEmpty() - ) - null - else "Forwarded" - } - 2 -> - "File" // AttachmentType.FILE = 2 - 3 -> - "Avatar" // AttachmentType.AVATAR = 3 - else -> null - } - } else null - } else null - } catch (e: Exception) { - null - } + resolveAttachmentType( + attachmentsJson = + dialog.lastMessageAttachments, + decryptedLastMessage = + decryptedLastMessage + ) DialogUiModel( id = dialog.id, @@ -493,6 +390,80 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } } + private fun decryptDialogPreview(encryptedLastMessage: String, privateKey: String): String { + if (encryptedLastMessage.isEmpty()) return "" + + if (privateKey.isEmpty()) { + return if (isLikelyEncryptedPayload(encryptedLastMessage)) "" else encryptedLastMessage + } + + val decrypted = + try { + CryptoManager.decryptWithPassword(encryptedLastMessage, privateKey) + } catch (e: Exception) { + null + } + + return when { + decrypted != null -> decrypted + isLikelyEncryptedPayload(encryptedLastMessage) -> "" + else -> encryptedLastMessage + } + } + + private fun resolveAttachmentType( + attachmentsJson: String, + decryptedLastMessage: String + ): String? { + if (attachmentsJson.isEmpty() || attachmentsJson == "[]") return null + + return try { + val attachments = org.json.JSONArray(attachmentsJson) + if (attachments.length() == 0) return null + + val firstAttachment = attachments.getJSONObject(0) + when (firstAttachment.optInt("type", -1)) { + 0 -> "Photo" // AttachmentType.IMAGE = 0 + 1 -> { + // AttachmentType.MESSAGES = 1 (Reply/Forward). + // Если текст пустой — показываем "Forwarded" как в desktop. + if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" + } + 2 -> "File" // AttachmentType.FILE = 2 + 3 -> "Avatar" // AttachmentType.AVATAR = 3 + else -> null + } + } catch (e: Exception) { + // Fallback: если JSON поврежден, но видно MESSAGES attachment и текста нет — это forward. + val hasMessagesType = + attachmentsJson.contains("\"type\":1") || + attachmentsJson.contains("\"type\": 1") + if (decryptedLastMessage.isEmpty() && hasMessagesType) { + "Forwarded" + } else { + null + } + } + } + + private fun isLikelyEncryptedPayload(value: String): Boolean { + if (value.startsWith("CHNK:")) return true + + val parts = value.split(":", limit = 2) + if (parts.size != 2) return false + + fun isBase64Like(part: String): Boolean { + if (part.length < 12) return false + return part.all { ch -> + ch.isLetterOrDigit() || + ch == '+' || ch == '/' || ch == '=' || + ch == '-' || ch == '_' + } + } + + return isBase64Like(parts[0]) && isBase64Like(parts[1]) + } + /** * Создать или обновить диалог после отправки/получения сообщения 🔥 Используем * updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует 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 index dccf5ba..882f57a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -225,9 +225,9 @@ fun ForwardChatPickerBottomSheet( onClick = { view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) selectedChats = if (isSelected) { - emptySet() + selectedChats - dialog.opponentKey } else { - setOf(dialog.opponentKey) + selectedChats + dialog.opponentKey } } ) @@ -275,7 +275,7 @@ fun ForwardChatPickerBottomSheet( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = if (hasSelection) "Forward" else "Select a chat", + text = if (hasSelection) "Forward (${selectedChats.size})" else "Select a chat", fontSize = 16.sp, fontWeight = FontWeight.SemiBold ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 8255363..ca28e72 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -360,8 +360,8 @@ fun MessageBubble( Box( modifier = - Modifier.fillMaxWidth().pointerInput(isSafeSystemMessage) { - if (isSafeSystemMessage) return@pointerInput + Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) { + if (isSystemSafeChat) return@pointerInput // 🔥 Простой горизонтальный свайп для reply // Используем detectHorizontalDragGestures который лучше работает со // скроллом