feat: Enhance message selection and forwarding logic, ensuring avatar messages are excluded
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -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: Использует
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 который лучше работает со
|
||||
// скроллом
|
||||
|
||||
Reference in New Issue
Block a user