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 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<ChatMessage>(
@@ -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<ChatMessage>(
@@ -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<ChatMessage>(
@@ -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)
}
}
)

View File

@@ -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) { }
}

View File

@@ -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
)
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
// 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: Использует

View File

@@ -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
)

View File

@@ -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 который лучше работает со
// скроллом