feat: Enhance message selection and forwarding logic, ensuring avatar messages are excluded

This commit is contained in:
2026-02-20 03:46:06 +05:00
parent 88e2084f8b
commit f1252bf328
5 changed files with 199 additions and 211 deletions

View File

@@ -379,6 +379,35 @@ fun ChatDetailScreen(
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val isForwardMode by viewModel.isForwardMode.collectAsState() 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 // NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true
// автоматически сохраняет позицию благодаря стабильным ключам (key = message.id) // автоматически сохраняет позицию благодаря стабильным ключам (key = message.id)
@@ -661,9 +690,10 @@ fun ChatDetailScreen(
messages messages
.filter { .filter {
selectedMessages selectedMessages
.contains( .contains(it.id) &&
it.id it.attachments.none { attachment ->
) attachment.type == AttachmentType.AVATAR
}
} }
.sortedWith( .sortedWith(
compareBy<ChatMessage>( compareBy<ChatMessage>(
@@ -718,9 +748,10 @@ fun ChatDetailScreen(
messages messages
.filter { .filter {
selectedMessages selectedMessages
.contains( .contains(it.id) &&
it.id it.attachments.none { attachment ->
) attachment.type == AttachmentType.AVATAR
}
} }
.forEach { .forEach {
msg msg
@@ -1216,6 +1247,7 @@ fun ChatDetailScreen(
label = "bottomBarContent" label = "bottomBarContent"
) { selectionMode -> ) { selectionMode ->
if (selectionMode) { if (selectionMode) {
if (!isSystemAccount) {
// SELECTION ACTION BAR - Reply/Forward // SELECTION ACTION BAR - Reply/Forward
Column( Column(
modifier = modifier =
@@ -1321,9 +1353,10 @@ fun ChatDetailScreen(
messages messages
.filter { .filter {
selectedMessages selectedMessages
.contains( .contains(it.id) &&
it.id it.attachments.none { attachment ->
) attachment.type == AttachmentType.AVATAR
}
} }
.sortedWith( .sortedWith(
compareBy<ChatMessage>( compareBy<ChatMessage>(
@@ -1407,9 +1440,10 @@ fun ChatDetailScreen(
messages messages
.filter { .filter {
selectedMessages selectedMessages
.contains( .contains(it.id) &&
it.id it.attachments.none { attachment ->
) attachment.type == AttachmentType.AVATAR
}
} }
.sortedWith( .sortedWith(
compareBy<ChatMessage>( compareBy<ChatMessage>(
@@ -1556,6 +1590,7 @@ fun ChatDetailScreen(
} }
} }
} }
}
} else if (!isSystemAccount) { } else if (!isSystemAccount) {
// INPUT BAR // INPUT BAR
Column { Column {
@@ -1972,18 +2007,10 @@ fun ChatDetailScreen(
showEmojiPicker = showEmojiPicker =
false false
} }
selectedMessages = toggleMessageSelection(
if (selectedMessages selectionKey,
.contains( true
selectionKey
) )
) {
selectedMessages -
selectionKey
} else {
selectedMessages +
selectionKey
}
}, },
onClick = { onClick = {
val hasAvatar = val hasAvatar =
@@ -1993,20 +2020,11 @@ fun ChatDetailScreen(
AttachmentType AttachmentType
.AVATAR .AVATAR
} }
if (isSelectionMode && !hasAvatar if (isSelectionMode) {
) { toggleMessageSelection(
selectedMessages = selectionKey,
if (selectedMessages !hasAvatar
.contains(
selectionKey
) )
) {
selectedMessages -
selectionKey
} else {
selectedMessages +
selectionKey
}
} }
}, },
onSwipeToReply = { onSwipeToReply = {
@@ -2018,7 +2036,8 @@ fun ChatDetailScreen(
AttachmentType AttachmentType
.AVATAR .AVATAR
} }
if (!hasAvatar if (!hasAvatar &&
!isSystemAccount
) { ) {
viewModel viewModel
.setReplyMessages( .setReplyMessages(
@@ -2364,24 +2383,28 @@ fun ChatDetailScreen(
}, },
onChatsSelected = { selectedDialogs -> onChatsSelected = { selectedDialogs ->
showForwardPicker = false showForwardPicker = false
if (selectedDialogs.isNotEmpty()) { if (selectedDialogs.isEmpty()) {
val primaryDialog = selectedDialogs.first() ForwardManager.clear()
val additionalDialogs = selectedDialogs.drop(1) return@ForwardChatPickerBottomSheet
}
// Отправляем forward напрямую в дополнительные чаты val forwardMessages = ForwardManager.consumeForwardMessages()
val fwdMessages = ForwardManager.consumeForwardMessages() ForwardManager.clear()
if (additionalDialogs.isNotEmpty() && fwdMessages.isNotEmpty()) { if (forwardMessages.isEmpty()) {
additionalDialogs.forEach { dialog -> return@ForwardChatPickerBottomSheet
}
// Реальная отправка forward во все выбранные чаты.
selectedDialogs.forEach { dialog ->
viewModel.sendForwardDirectly( viewModel.sendForwardDirectly(
dialog.opponentKey, dialog.opponentKey,
fwdMessages forwardMessages
) )
} }
}
// Навигируемся в первый выбранный чат с forward // Открываем первый выбранный чат (кроме текущего), чтобы сразу увидеть результат.
ForwardManager.setForwardMessages(fwdMessages, showPicker = false) val primaryDialog = selectedDialogs.first()
ForwardManager.selectChat(primaryDialog.opponentKey) if (primaryDialog.opponentKey != user.publicKey) {
val searchUser = val searchUser =
SearchUser( SearchUser(
title = primaryDialog.opponentTitle, title = primaryDialog.opponentTitle,

View File

@@ -2210,23 +2210,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = if (isSavedMessages) 1 else 0, delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson,
opponentPublicKey = recipientPublicKey
) )
// Сохраняем диалог (для списка чатов) // Обновляем диалог (для списка чатов) из таблицы сообщений.
val db = RosettaDatabase.getDatabase(context) val db = RosettaDatabase.getDatabase(context)
val dialogDao = db.dialogDao() val dialogDao = db.dialogDao()
val dialogKey = if (sender < recipientPublicKey) "$sender:$recipientPublicKey" if (isSavedMessages) {
else "$recipientPublicKey:$sender" dialogDao.updateSavedMessagesDialogFromMessages(sender)
val existingDialog = dialogDao.getDialog(sender, recipientPublicKey) } else {
val encryptedLastMsg = CryptoManager.encryptWithPassword("Forwarded message", privateKey) dialogDao.updateDialogFromMessages(sender, recipientPublicKey)
if (existingDialog != null) {
dialogDao.updateLastMessage(
sender,
recipientPublicKey,
encryptedLastMsg,
timestamp
)
} }
} catch (e: Exception) { } } catch (e: Exception) { }
} }

View File

@@ -181,25 +181,13 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
loadUserInfoForDialog(dialog.opponentKey) loadUserInfoForDialog(dialog.opponentKey)
} }
// 🚀 Расшифровка теперь кэшируется в CryptoManager! // Безопасная дешифровка превью: никогда не показываем raw ciphertext.
val decryptedLastMessage = val decryptedLastMessage =
try { decryptDialogPreview(
if (privateKey.isNotEmpty() && encryptedLastMessage =
dialog.lastMessage
.isNotEmpty()
) {
CryptoManager.decryptWithPassword(
dialog.lastMessage, dialog.lastMessage,
privateKey privateKey = privateKey
) )
?: dialog.lastMessage
} else {
dialog.lastMessage
}
} catch (e: Exception) {
dialog.lastMessage // Fallback на
// зашифрованный текст
}
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из // <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
// DialogEntity // DialogEntity
@@ -211,52 +199,12 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 📎 Определяем тип attachment из кэшированного поля в // 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity // DialogEntity
val attachmentType = val attachmentType =
try { resolveAttachmentType(
val attachmentsJson = attachmentsJson =
dialog.lastMessageAttachments dialog.lastMessageAttachments,
if (attachmentsJson.isNotEmpty() && decryptedLastMessage =
attachmentsJson != "[]" decryptedLastMessage
) {
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
}
DialogUiModel( DialogUiModel(
id = dialog.id, id = dialog.id,
@@ -336,74 +284,23 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
loadUserInfoForRequest(dialog.opponentKey) loadUserInfoForRequest(dialog.opponentKey)
} }
// 🚀 Расшифровка теперь кэшируется в CryptoManager! // Безопасная дешифровка превью: никогда не показываем raw ciphertext.
val decryptedLastMessage = val decryptedLastMessage =
try { decryptDialogPreview(
if (privateKey.isNotEmpty() && encryptedLastMessage =
dialog.lastMessage
.isNotEmpty()
) {
CryptoManager.decryptWithPassword(
dialog.lastMessage, dialog.lastMessage,
privateKey privateKey = privateKey
) )
?: dialog.lastMessage
} else {
dialog.lastMessage
}
} catch (e: Exception) {
dialog.lastMessage
}
// 📎 Определяем тип attachment из кэшированного поля в // 📎 Определяем тип attachment из кэшированного поля в
// DialogEntity // DialogEntity
val attachmentType = val attachmentType =
try { resolveAttachmentType(
val attachmentsJson = attachmentsJson =
dialog.lastMessageAttachments dialog.lastMessageAttachments,
if (attachmentsJson.isNotEmpty() && decryptedLastMessage =
attachmentsJson != "[]" decryptedLastMessage
) {
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
}
DialogUiModel( DialogUiModel(
id = dialog.id, 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: Использует * updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует

View File

@@ -225,9 +225,9 @@ fun ForwardChatPickerBottomSheet(
onClick = { onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
selectedChats = if (isSelected) { selectedChats = if (isSelected) {
emptySet() selectedChats - dialog.opponentKey
} else { } else {
setOf(dialog.opponentKey) selectedChats + dialog.opponentKey
} }
} }
) )
@@ -275,7 +275,7 @@ fun ForwardChatPickerBottomSheet(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = if (hasSelection) "Forward" else "Select a chat", text = if (hasSelection) "Forward (${selectedChats.size})" else "Select a chat",
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )

View File

@@ -360,8 +360,8 @@ fun MessageBubble(
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth().pointerInput(isSafeSystemMessage) { Modifier.fillMaxWidth().pointerInput(isSystemSafeChat) {
if (isSafeSystemMessage) return@pointerInput if (isSystemSafeChat) return@pointerInput
// 🔥 Простой горизонтальный свайп для reply // 🔥 Простой горизонтальный свайп для reply
// Используем detectHorizontalDragGestures который лучше работает со // Используем detectHorizontalDragGestures который лучше работает со
// скроллом // скроллом