feat: Implement special handling for Saved Messages, including dedicated methods for retrieval, display, and dialog updates

This commit is contained in:
k1ngsterr1
2026-01-18 12:28:28 +05:00
parent 52523d91fb
commit 5833237c3a
7 changed files with 363 additions and 60 deletions

View File

@@ -158,7 +158,11 @@ class MessageRepository private constructor(private val context: Context) {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
val dialogKey = getDialogKey(toPublicKey) val dialogKey = getDialogKey(toPublicKey)
// 📁 Проверяем является ли это Saved Messages
val isSavedMessages = (account == toPublicKey)
// 1. Создаем оптимистичное сообщение // 1. Создаем оптимистичное сообщение
// 📁 Для saved messages - сразу DELIVERED и прочитано
val optimisticMessage = Message( val optimisticMessage = Message(
messageId = messageId, messageId = messageId,
fromPublicKey = account, fromPublicKey = account,
@@ -166,8 +170,8 @@ class MessageRepository private constructor(private val context: Context) {
content = text.trim(), content = text.trim(),
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
isRead = account == toPublicKey, // Если сам себе - сразу прочитано isRead = isSavedMessages, // 📁 Если сам себе - сразу прочитано
deliveryStatus = DeliveryStatus.WAITING, deliveryStatus = if (isSavedMessages) DeliveryStatus.DELIVERED else DeliveryStatus.WAITING, // 📁 Для saved messages - сразу доставлено
attachments = attachments, attachments = attachments,
replyToMessageId = replyToMessageId replyToMessageId = replyToMessageId
) )
@@ -194,6 +198,7 @@ class MessageRepository private constructor(private val context: Context) {
val exists = messageDao.messageExists(account, messageId) val exists = messageDao.messageExists(account, messageId)
if (!exists) { if (!exists) {
// Сохраняем в БД только если сообщения нет // Сохраняем в БД только если сообщения нет
// 📁 Для saved messages - сразу read=1 и delivered=DELIVERED
val entity = MessageEntity( val entity = MessageEntity(
account = account, account = account,
fromPublicKey = account, fromPublicKey = account,
@@ -201,9 +206,9 @@ class MessageRepository private constructor(private val context: Context) {
content = encryptedContent, content = encryptedContent,
timestamp = timestamp, timestamp = timestamp,
chachaKey = encryptedKey, chachaKey = encryptedKey,
read = if (account == toPublicKey) 1 else 0, read = if (isSavedMessages) 1 else 0,
fromMe = 1, fromMe = 1,
delivered = DeliveryStatus.WAITING.value, delivered = if (isSavedMessages) DeliveryStatus.DELIVERED.value else DeliveryStatus.WAITING.value,
messageId = messageId, messageId = messageId,
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson, attachments = attachmentsJson,
@@ -219,7 +224,13 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats) // 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats)
val updatedRows = dialogDao.markIHaveSent(account, toPublicKey) val updatedRows = dialogDao.markIHaveSent(account, toPublicKey)
// Отправляем пакет // 📁 НЕ отправляем пакет на сервер для saved messages!
// Как в Архиве: if(publicKey == opponentPublicKey) return;
if (isSavedMessages) {
return@launch // Для saved messages - только локальное сохранение, без отправки на сервер
}
// Отправляем пакет (только для обычных диалогов)
val packet = PacketMessage().apply { val packet = PacketMessage().apply {
this.fromPublicKey = account this.fromPublicKey = account
this.toPublicKey = toPublicKey this.toPublicKey = toPublicKey
@@ -375,6 +386,7 @@ class MessageRepository private constructor(private val context: Context) {
/** /**
* Отметить диалог как прочитанный * Отметить диалог как прочитанный
* 🔥 После обновления messages обновляем диалог через updateDialogFromMessages * 🔥 После обновления messages обновляем диалог через updateDialogFromMessages
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
*/ */
suspend fun markDialogAsRead(opponentKey: String) { suspend fun markDialogAsRead(opponentKey: String) {
val account = currentAccount ?: return val account = currentAccount ?: return
@@ -385,17 +397,28 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Пересчитываем счетчики из таблицы messages // 🔥 КРИТИЧНО: Пересчитываем счетчики из таблицы messages
// чтобы unread_count обновился моментально // чтобы unread_count обновился моментально
dialogDao.updateDialogFromMessages(account, opponentKey) // 📁 Используем специальный метод для saved messages
if (opponentKey == account) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponentKey)
}
} }
/** /**
* Отправить уведомление "печатает" * Отправить уведомление "печатает"
* 📁 Для Saved Messages - не отправляем
*/ */
fun sendTyping(toPublicKey: String) { fun sendTyping(toPublicKey: String) {
val account = currentAccount ?: return val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return val privateKey = currentPrivateKey ?: return
// 📁 Для Saved Messages - не отправляем typing
if (account == toPublicKey) {
return
}
scope.launch { scope.launch {
val packet = PacketTyping().apply { val packet = PacketTyping().apply {
this.fromPublicKey = account this.fromPublicKey = account
@@ -435,8 +458,17 @@ class MessageRepository private constructor(private val context: Context) {
// Private helpers // Private helpers
// =============================== // ===============================
/**
* Получить ключ диалога для группировки сообщений
* 📁 SAVED MESSAGES: Для saved messages (account == opponentKey) возвращает просто account
*/
private fun getDialogKey(opponentKey: String): String { private fun getDialogKey(opponentKey: String): String {
val account = currentAccount ?: return opponentKey val account = currentAccount ?: return opponentKey
// Для saved messages dialog_key = просто publicKey
if (account == opponentKey) {
return account
}
// Для обычных диалогов - сортируем ключи
return if (account < opponentKey) "$account:$opponentKey" return if (account < opponentKey) "$account:$opponentKey"
else "$opponentKey:$account" else "$opponentKey:$account"
} }

View File

@@ -138,6 +138,32 @@ interface MessageDao {
""") """)
suspend fun getMessages(account: String, dialogKey: String, limit: Int, offset: Int): List<MessageEntity> suspend fun getMessages(account: String, dialogKey: String, limit: Int, offset: Int): List<MessageEntity>
/**
* 📁 Получить сообщения для Saved Messages (постранично)
* Специальный метод для случая когда from_public_key = to_public_key = account
* Использует упрощенный запрос без дублирования OR условий
*/
@Query("""
SELECT * FROM messages
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
""")
suspend fun getMessagesForSavedDialog(account: String, limit: Int, offset: Int): List<MessageEntity>
/**
* 📁 Получить количество сообщений в Saved Messages
*/
@Query("""
SELECT COUNT(*) FROM messages
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
""")
suspend fun getMessageCountForSavedDialog(account: String): Int
/** /**
* Получить сообщения диалога как Flow * Получить сообщения диалога как Flow
*/ */
@@ -508,4 +534,77 @@ interface DialogDao {
) )
""") """)
suspend fun updateDialogFromMessages(account: String, opponentKey: String) suspend fun updateDialogFromMessages(account: String, opponentKey: String)
/**
* 📁 Обновить Saved Messages диалог, пересчитав счетчики из таблицы messages
* Специальный метод для случая когда opponentKey == account (saved messages)
* Использует упрощенный запрос без дублирования OR условий
*
* Ключевые отличия от обычного updateDialogFromMessages:
* 1. Упрощенные WHERE условия: from_public_key = :account AND to_public_key = :account
* 2. unread_count всегда 0 (нельзя иметь непрочитанные от самого себя)
* 3. i_have_sent всегда 1 (все сообщения исходящие)
*/
@Query("""
INSERT OR REPLACE INTO dialogs (
account,
opponent_key,
opponent_title,
opponent_username,
last_message,
last_message_timestamp,
unread_count,
is_online,
last_seen,
verified,
i_have_sent
)
SELECT
:account AS account,
:account AS opponent_key,
COALESCE(
(SELECT opponent_title FROM dialogs WHERE account = :account AND opponent_key = :account),
''
) AS opponent_title,
COALESCE(
(SELECT opponent_username FROM dialogs WHERE account = :account AND opponent_key = :account),
''
) AS opponent_username,
COALESCE(
(SELECT plain_message FROM messages
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
ORDER BY timestamp DESC LIMIT 1),
''
) AS last_message,
COALESCE(
(SELECT MAX(timestamp) FROM messages
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account),
0
) AS last_message_timestamp,
0 AS unread_count,
COALESCE(
(SELECT is_online FROM dialogs WHERE account = :account AND opponent_key = :account),
0
) AS is_online,
COALESCE(
(SELECT last_seen FROM dialogs WHERE account = :account AND opponent_key = :account),
0
) AS last_seen,
COALESCE(
(SELECT verified FROM dialogs WHERE account = :account AND opponent_key = :account),
0
) AS verified,
1 AS i_have_sent
WHERE EXISTS (
SELECT 1 FROM messages
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
)
""")
suspend fun updateSavedMessagesDialogFromMessages(account: String)
} }

View File

@@ -174,24 +174,9 @@ object ProtocolManager {
} }
} }
// 🔥 Обработчик поиска (0x03) - обновляет информацию о пользователях в диалогах // 🔥 УБРАН обработчик поиска (0x03) из ProtocolManager
waitPacket(0x03) { packet -> // Он вызывал бесконечный цикл т.к. updateDialogUserInfo триггерил Flow
val searchPacket = packet as PacketSearch // Обработка 0x03 происходит только в SearchUsersViewModel
if (searchPacket.users.isNotEmpty()) {
addLog("📋 Search response: ${searchPacket.users.size} users")
scope.launch {
searchPacket.users.forEach { user ->
addLog(" Updating user info: ${user.publicKey.take(16)}... title=${user.title}, username=${user.username}")
messageRepository?.updateDialogUserInfo(
user.publicKey,
user.title,
user.username,
user.verified
)
}
}
}
}
} }
/** /**

View File

@@ -171,13 +171,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* 🚀 Инкрементальное добавление последнего сообщения из БД * 🚀 Инкрементальное добавление последнего сообщения из БД
* Вместо полной перезагрузки списка - добавляем только новое сообщение * Вместо полной перезагрузки списка - добавляем только новое сообщение
* Это предотвращает "прыгание" пузырьков в Compose * Это предотвращает "прыгание" пузырьков в Compose
* 📁 SAVED MESSAGES: Использует специальные методы для saved messages
*/ */
private fun addLatestMessageFromDb(account: String, dialogKey: String) { private fun addLatestMessageFromDb(account: String, dialogKey: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
// 📁 Проверяем является ли это Saved Messages
val opponent = opponentKey ?: return@launch
val isSavedMessages = (opponent == account)
// Получаем последнее сообщение из БД // Получаем последнее сообщение из БД
val latestEntity = messageDao.getMessages(account, dialogKey, limit = 1, offset = 0).firstOrNull() val latestEntity = if (isSavedMessages) {
?: return@launch messageDao.getMessagesForSavedDialog(account, limit = 1, offset = 0).firstOrNull()
} else {
messageDao.getMessages(account, dialogKey, limit = 1, offset = 0).firstOrNull()
} ?: return@launch
// Проверяем, есть ли это сообщение уже в списке // Проверяем, есть ли это сообщение уже в списке
val existingIds = _messages.value.map { it.id }.toSet() val existingIds = _messages.value.map { it.id }.toSet()
@@ -201,12 +209,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 👁️ Фоновые операции // 👁️ Фоновые операции
if (isDialogActive) { if (isDialogActive) {
messageDao.markDialogAsRead(account, dialogKey) messageDao.markDialogAsRead(account, dialogKey)
// Отправляем read receipt // Отправляем read receipt (НЕ для saved messages!)
if (!newMessage.isOutgoing) { if (!newMessage.isOutgoing && !isSavedMessages) {
sendReadReceiptToOpponent() sendReadReceiptToOpponent()
} }
} }
dialogDao.updateDialogFromMessages(account, opponentKey ?: return@launch)
// Обновляем диалог - используем специальный метод для saved messages
if (isSavedMessages) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
} catch (e: Exception) { } catch (e: Exception) {
} }
@@ -379,12 +393,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* 🚀 СУПЕР-оптимизированная загрузка сообщений * 🚀 СУПЕР-оптимизированная загрузка сообщений
* 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка * 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка
* 📁 SAVED MESSAGES: Использует специальные методы для saved messages чтобы избежать дублирования
*/ */
private fun loadMessagesFromDatabase(delayMs: Long = 0L) { private fun loadMessagesFromDatabase(delayMs: Long = 0L) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
val opponent = opponentKey ?: return val opponent = opponentKey ?: return
val dialogKey = getDialogKey(account, opponent) val dialogKey = getDialogKey(account, opponent)
// 📁 Проверяем является ли это Saved Messages
val isSavedMessages = (opponent == account)
if (isLoadingMessages) return if (isLoadingMessages) return
isLoadingMessages = true isLoadingMessages = true
@@ -400,14 +418,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Фоновое обновление из БД (новые сообщения) // Фоновое обновление из БД (новые сообщения)
delay(100) // Небольшая задержка чтобы UI успел отрисоваться delay(100) // Небольшая задержка чтобы UI успел отрисоваться
refreshMessagesFromDb(account, opponent, dialogKey, cachedMessages) refreshMessagesFromDb(account, opponent, dialogKey, cachedMessages, isSavedMessages)
isLoadingMessages = false isLoadingMessages = false
return@launch return@launch
} }
// 🔥 Нет кэша - проверяем есть ли вообще сообщения в БД // 🔥 Нет кэша - проверяем есть ли вообще сообщения в БД
// Если диалог пустой - не показываем скелетон! // Если диалог пустой - не показываем скелетон!
val totalCount = messageDao.getMessageCount(account, dialogKey) val totalCount = if (isSavedMessages) {
messageDao.getMessageCountForSavedDialog(account)
} else {
messageDao.getMessageCount(account, dialogKey)
}
if (totalCount == 0) { if (totalCount == 0) {
// Пустой диалог - сразу показываем пустое состояние без скелетона // Пустой диалог - сразу показываем пустое состояние без скелетона
@@ -428,8 +450,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 🔥 Получаем первую страницу - БЕЗ suspend задержки // 🔥 Получаем первую страницу - используем специальный метод для saved messages
val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) val entities = if (isSavedMessages) {
messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = 0)
} else {
messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0)
}
hasMoreMessages = entities.size >= PAGE_SIZE hasMoreMessages = entities.size >= PAGE_SIZE
@@ -469,11 +495,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isDialogActive) { if (isDialogActive) {
messageDao.markDialogAsRead(account, dialogKey) messageDao.markDialogAsRead(account, dialogKey)
} }
// 🔥 Пересчитываем счетчики из messages // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved messages
dialogDao.updateDialogFromMessages(account, opponent) if (isSavedMessages) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
// Отправляем read receipt собеседнику // Отправляем read receipt собеседнику (НЕ для saved messages!)
if (messages.isNotEmpty()) { if (!isSavedMessages && messages.isNotEmpty()) {
val lastIncoming = messages.lastOrNull { !it.isOutgoing } val lastIncoming = messages.lastOrNull { !it.isOutgoing }
if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) { if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) {
sendReadReceiptToOpponent() sendReadReceiptToOpponent()
@@ -496,15 +526,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* 🔥 Фоновое обновление сообщений из БД (проверка новых) * 🔥 Фоновое обновление сообщений из БД (проверка новых)
* Вызывается когда кэш уже отображён, но нужно проверить есть ли новые сообщения * Вызывается когда кэш уже отображён, но нужно проверить есть ли новые сообщения
* 🔥 ВАЖНО: НЕ заменяем все сообщения - только добавляем новые, сохраняя существующие! * 🔥 ВАЖНО: НЕ заменяем все сообщения - только добавляем новые, сохраняя существующие!
* 📁 SAVED MESSAGES: Использует специальные методы для saved messages
*/ */
private suspend fun refreshMessagesFromDb( private suspend fun refreshMessagesFromDb(
account: String, account: String,
opponent: String, opponent: String,
dialogKey: String, dialogKey: String,
cachedMessages: List<ChatMessage> cachedMessages: List<ChatMessage>,
isSavedMessages: Boolean
) { ) {
try { try {
val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) val entities = if (isSavedMessages) {
messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = 0)
} else {
messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0)
}
// 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения) // 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения)
val currentMessages = _messages.value val currentMessages = _messages.value
@@ -537,8 +573,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isDialogActive) { if (isDialogActive) {
messageDao.markDialogAsRead(account, dialogKey) messageDao.markDialogAsRead(account, dialogKey)
} }
// 🔥 Пересчитываем счетчики из messages // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved messages
dialogDao.updateDialogFromMessages(account, opponent) if (isSavedMessages) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
} catch (e: Exception) { } catch (e: Exception) {
} }
@@ -548,11 +588,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* 🚀 Загрузка следующей страницы (для бесконечной прокрутки) * 🚀 Загрузка следующей страницы (для бесконечной прокрутки)
* 📁 SAVED MESSAGES: Использует специальные методы для saved messages
*/ */
fun loadMoreMessages() { fun loadMoreMessages() {
val account = myPublicKey ?: return val account = myPublicKey ?: return
val opponent = opponentKey ?: return val opponent = opponentKey ?: return
// 📁 Проверяем является ли это Saved Messages
val isSavedMessages = (opponent == account)
if (!hasMoreMessages || isLoadingMessages) return if (!hasMoreMessages || isLoadingMessages) return
isLoadingMessages = true isLoadingMessages = true
@@ -564,7 +608,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val dialogKey = getDialogKey(account, opponent) val dialogKey = getDialogKey(account, opponent)
val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = currentOffset) val entities = if (isSavedMessages) {
messageDao.getMessagesForSavedDialog(account, limit = PAGE_SIZE, offset = currentOffset)
} else {
messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = currentOffset)
}
hasMoreMessages = entities.size >= PAGE_SIZE hasMoreMessages = entities.size >= PAGE_SIZE
currentOffset += entities.size currentOffset += entities.size
@@ -844,8 +892,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* Получить ключ диалога для группировки сообщений * Получить ключ диалога для группировки сообщений
* 📁 SAVED MESSAGES: Для saved messages (account == opponent) возвращает просто account
*/ */
private fun getDialogKey(account: String, opponent: String): String { private fun getDialogKey(account: String, opponent: String): String {
// Для saved messages dialog_key = просто publicKey
if (account == opponent) {
return account
}
// Для обычных диалогов - сортируем ключи
return if (account < opponent) { return if (account < opponent) {
"$account:$opponent" "$account:$opponent"
} else { } else {
@@ -1072,11 +1126,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
packet.attachments.forEachIndexed { idx, att -> packet.attachments.forEachIndexed { idx, att ->
} }
// Отправляем пакет // 📁 Для Saved Messages - НЕ отправляем пакет на сервер
ProtocolManager.send(packet) // Только сохраняем локально
val isSavedMessages = (sender == recipient)
if (!isSavedMessages) {
// Отправляем пакет только для обычных диалогов
ProtocolManager.send(packet)
}
// 3. 🎯 UI обновление в Main потоке // 3. 🎯 UI обновление в Main потоке
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// 📁 Для Saved Messages - сразу SENT, для обычных - ждём delivery
updateMessageStatus(messageId, MessageStatus.SENT) updateMessageStatus(messageId, MessageStatus.SENT)
} }
@@ -1102,7 +1162,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
encryptedKey = encryptedKey, encryptedKey = encryptedKey,
timestamp = timestamp, timestamp = timestamp,
isFromMe = true, isFromMe = true,
delivered = 0, // 🔥 SENDING - ждём PacketDelivery для DELIVERED delivered = if (isSavedMessages) 2 else 0, // 📁 Saved Messages: сразу DELIVERED (2), иначе SENDING (0)
attachmentsJson = attachmentsJson attachmentsJson = attachmentsJson
) )
@@ -1120,7 +1180,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* Сохранить диалог в базу данных * Сохранить диалог в базу данных
* <EFBFBD> Используем updateDialogFromMessages для пересчета счетчиков из messages * 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
*/ */
private suspend fun saveDialog(lastMessage: String, timestamp: Long) { private suspend fun saveDialog(lastMessage: String, timestamp: Long) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
@@ -1129,13 +1190,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
try { try {
// 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики
// напрямую из таблицы messages, как в Архиве! // напрямую из таблицы messages, как в Архиве!
dialogDao.updateDialogFromMessages(account, opponent) // 📁 Используем специальный метод для saved messages
if (opponent == account) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
} catch (e: Exception) { } catch (e: Exception) {
} }
} }
/** /**
* Обновить диалог при входящем сообщении * Обновить диалог при входящем сообщении
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
*/ */
private suspend fun updateDialog(opponentKey: String, lastMessage: String, timestamp: Long, incrementUnread: Boolean) { private suspend fun updateDialog(opponentKey: String, lastMessage: String, timestamp: Long, incrementUnread: Boolean) {
val account = myPublicKey ?: return val account = myPublicKey ?: return
@@ -1144,7 +1211,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики
// напрямую из таблицы messages, как в Архиве! // напрямую из таблицы messages, как в Архиве!
// Это гарантирует что unread_count всегда соответствует реальному количеству непрочитанных // Это гарантирует что unread_count всегда соответствует реальному количеству непрочитанных
dialogDao.updateDialogFromMessages(account, opponentKey) // 📁 Используем специальный метод для saved messages
if (opponentKey == account) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponentKey)
}
} catch (e: Exception) { } catch (e: Exception) {
} }
} }
@@ -1225,6 +1297,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* 📝 Отправить индикатор "печатает..." * 📝 Отправить индикатор "печатает..."
* С throttling чтобы не спамить сервер * С throttling чтобы не спамить сервер
* 📁 Для Saved Messages - не отправляем (нельзя печатать самому себе)
*/ */
fun sendTypingIndicator() { fun sendTypingIndicator() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -1236,6 +1309,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey ?: run { val sender = myPublicKey ?: run {
return return
} }
// 📁 Для Saved Messages - не отправляем typing indicator
if (opponent == sender) {
return
}
val privateKey = myPrivateKey ?: run { val privateKey = myPrivateKey ?: run {
return return
} }
@@ -1263,6 +1342,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
* 👁️ Отправить read receipt собеседнику * 👁️ Отправить read receipt собеседнику
* Как в архиве - просто отправляем PacketRead без messageId * Как в архиве - просто отправляем PacketRead без messageId
* Означает что мы прочитали все сообщения от этого собеседника * Означает что мы прочитали все сообщения от этого собеседника
* 📁 SAVED MESSAGES: НЕ отправляет read receipt для saved messages (нельзя слать самому себе)
*/ */
private fun sendReadReceiptToOpponent() { private fun sendReadReceiptToOpponent() {
// 🔥 Не отправляем read receipt если диалог не активен (как в архиве) // 🔥 Не отправляем read receipt если диалог не активен (как в архиве)
@@ -1272,6 +1352,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val opponent = opponentKey ?: return val opponent = opponentKey ?: return
val sender = myPublicKey ?: return val sender = myPublicKey ?: return
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
if (opponent == sender) {
return
}
val privateKey = myPrivateKey ?: return val privateKey = myPrivateKey ?: return
// Обновляем timestamp последнего прочитанного // Обновляем timestamp последнего прочитанного
@@ -1325,7 +1411,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val dialogKey = getDialogKey(account, opponent) val dialogKey = getDialogKey(account, opponent)
messageDao.markDialogAsRead(account, dialogKey) messageDao.markDialogAsRead(account, dialogKey)
// 🔥 Пересчитываем счетчики из messages // 🔥 Пересчитываем счетчики из messages
dialogDao.updateDialogFromMessages(account, opponent) // 📁 Используем специальный метод для saved messages
if (opponent == account) {
dialogDao.updateSavedMessagesDialogFromMessages(account)
} else {
dialogDao.updateDialogFromMessages(account, opponent)
}
} catch (e: Exception) { } catch (e: Exception) {
} }
} }
@@ -1336,10 +1427,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* 🟢 Подписаться на онлайн статус собеседника * 🟢 Подписаться на онлайн статус собеседника
* 📁 Для Saved Messages - не подписываемся
*/ */
fun subscribeToOnlineStatus() { fun subscribeToOnlineStatus() {
val opponent = opponentKey ?: return val opponent = opponentKey ?: return
val privateKey = myPrivateKey ?: return val privateKey = myPrivateKey ?: return
val account = myPublicKey ?: return
// 📁 Для Saved Messages - не нужно подписываться на свой собственный статус
if (account == opponent) {
return
}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {

View File

@@ -1551,13 +1551,23 @@ fun DialogItemContent(
remember(dialog.opponentKey, isDarkTheme) { remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme) getAvatarColor(dialog.opponentKey, isDarkTheme)
} }
// 📁 Для Saved Messages показываем специальное имя
val displayName = val displayName =
remember(dialog.opponentTitle, dialog.opponentKey) { remember(dialog.opponentTitle, dialog.opponentKey, dialog.isSavedMessages) {
dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } if (dialog.isSavedMessages) {
"Saved Messages"
} else {
dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) }
}
} }
// 📁 Для Saved Messages показываем иконку закладки
val initials = val initials =
remember(dialog.opponentTitle, dialog.opponentKey) { remember(dialog.opponentTitle, dialog.opponentKey, dialog.isSavedMessages) {
if (dialog.opponentTitle.isNotEmpty()) { if (dialog.isSavedMessages) {
"📁" // Иконка для Saved Messages
} else if (dialog.opponentTitle.isNotEmpty()) {
dialog.opponentTitle dialog.opponentTitle
.split(" ") .split(" ")
.take(2) .take(2)

View File

@@ -29,7 +29,8 @@ data class DialogUiModel(
val unreadCount: Int, val unreadCount: Int,
val isOnline: Int, val isOnline: Int,
val lastSeen: Long, val lastSeen: Long,
val verified: Int val verified: Int,
val isSavedMessages: Boolean = false // 📁 Флаг для Saved Messages (account == opponentKey)
) )
/** /**
@@ -57,6 +58,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private var currentAccount: String = "" private var currentAccount: String = ""
private var currentPrivateKey: String? = null private var currentPrivateKey: String? = null
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
private val requestedUserInfoKeys = mutableSetOf<String>()
// Список диалогов с расшифрованными сообщениями // Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList()) private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow() val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
@@ -125,7 +129,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
unreadCount = dialog.unreadCount, unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline, isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen, lastSeen = dialog.lastSeen,
verified = dialog.verified verified = dialog.verified,
isSavedMessages = (dialog.account == dialog.opponentKey) // 📁 Saved Messages
) )
} }
} }
@@ -135,7 +140,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_dialogs.value = decryptedDialogs _dialogs.value = decryptedDialogs
// 🟢 Подписываемся на онлайн-статусы всех собеседников // 🟢 Подписываемся на онлайн-статусы всех собеседников
subscribeToOnlineStatuses(decryptedDialogs.map { it.opponentKey }, privateKey) // 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный статус
val opponentsToSubscribe = decryptedDialogs
.filter { !it.isSavedMessages }
.map { it.opponentKey }
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
} }
} }
@@ -144,9 +153,13 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao.getRequestsFlow(publicKey) dialogDao.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.map { requestsList -> .map { requestsList ->
android.util.Log.d("ChatsListVM", "📬 getRequestsFlow emitted: ${requestsList.size} requests")
requestsList.map { dialog -> requestsList.map { dialog ->
// 🔥 Загружаем информацию о пользователе если её нет // 🔥 Загружаем информацию о пользователе если её нет
if (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey) { // 📁 НЕ загружаем для Saved Messages
val isSavedMessages = (dialog.account == dialog.opponentKey)
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) {
android.util.Log.d("ChatsListVM", "📬 Request needs user info: ${dialog.opponentKey.take(16)}... title='${dialog.opponentTitle}'")
loadUserInfoForRequest(dialog.opponentKey) loadUserInfoForRequest(dialog.opponentKey)
} }
@@ -172,7 +185,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
unreadCount = dialog.unreadCount, unreadCount = dialog.unreadCount,
isOnline = dialog.isOnline, isOnline = dialog.isOnline,
lastSeen = dialog.lastSeen, lastSeen = dialog.lastSeen,
verified = dialog.verified verified = dialog.verified,
isSavedMessages = (dialog.account == dialog.opponentKey) // 📁 Saved Messages
) )
} }
} }
@@ -218,6 +232,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** /**
* Создать или обновить диалог после отправки/получения сообщения * Создать или обновить диалог после отправки/получения сообщения
* 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages * 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
*/ */
suspend fun upsertDialog( suspend fun upsertDialog(
opponentKey: String, opponentKey: String,
@@ -233,7 +248,12 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
try { try {
// 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики
// напрямую из таблицы messages, как в Архиве! // напрямую из таблицы messages, как в Архиве!
dialogDao.updateDialogFromMessages(currentAccount, opponentKey) // 📁 Используем специальный метод для saved messages
if (opponentKey == currentAccount) {
dialogDao.updateSavedMessagesDialogFromMessages(currentAccount)
} else {
dialogDao.updateDialogFromMessages(currentAccount, opponentKey)
}
// Обновляем информацию о собеседнике если есть // Обновляем информацию о собеседнике если есть
if (opponentTitle.isNotEmpty()) { if (opponentTitle.isNotEmpty()) {
@@ -349,8 +369,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** /**
* 📬 Загрузить информацию о пользователе для request * 📬 Загрузить информацию о пользователе для request
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
*/ */
private fun loadUserInfoForRequest(publicKey: String) { private fun loadUserInfoForRequest(publicKey: String) {
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
if (publicKey == currentAccount) {
android.util.Log.d("ChatsListVM", "📁 Skipping loadUserInfoForRequest for Saved Messages")
return
}
// 🔥 Не запрашиваем если уже запрашивали
if (requestedUserInfoKeys.contains(publicKey)) {
android.util.Log.d("ChatsListVM", "⏭️ Skipping loadUserInfoForRequest - already requested for ${publicKey.take(16)}...")
return
}
requestedUserInfoKeys.add(publicKey)
android.util.Log.d("ChatsListVM", "🔍 loadUserInfoForRequest: ${publicKey.take(16)}...")
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val sharedPrefs = getApplication<Application>().getSharedPreferences("rosetta", Application.MODE_PRIVATE) val sharedPrefs = getApplication<Application>().getSharedPreferences("rosetta", Application.MODE_PRIVATE)
@@ -361,6 +397,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo // 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
android.util.Log.d("ChatsListVM", "📤 Sending PacketSearch for user info: ${publicKey.take(16)}...")
// Запрашиваем информацию о пользователе с сервера // Запрашиваем информацию о пользователе с сервера
val packet = PacketSearch().apply { val packet = PacketSearch().apply {
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
@@ -368,6 +406,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
ProtocolManager.send(packet) ProtocolManager.send(packet)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ChatsListVM", "❌ loadUserInfoForRequest error: ${e.message}")
} }
} }
} }

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.PacketSearch
@@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val TAG = "SearchUsersVM"
/** /**
* ViewModel для поиска пользователей через протокол * ViewModel для поиска пользователей через протокол
* Работает аналогично SearchBar в React Native приложении * Работает аналогично SearchBar в React Native приложении
@@ -38,23 +41,50 @@ class SearchUsersViewModel : ViewModel() {
private var privateKeyHash: String = "" private var privateKeyHash: String = ""
// Callback для обработки ответа поиска // Callback для обработки ответа поиска
private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = { packet -> private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = handler@{ packet ->
if (packet is PacketSearch) { if (packet is PacketSearch) {
// 🔥 ВАЖНО: Игнорируем ответы с пустым search или не соответствующие нашему запросу
// Сервер может слать много пакетов 0x03 по разным причинам
val currentQuery = lastSearchedText
val responseSearch = packet.search
Log.d(TAG, "📥 PacketSearch received: ${packet.users.size} users, search='$responseSearch', ourQuery='$currentQuery'")
// Принимаем ответ только если:
// 1. search в ответе совпадает с нашим запросом, ИЛИ
// 2. search пустой но мы ждём ответ (lastSearchedText не пустой)
// НО: если search пустой и мы НЕ ждём ответ - игнорируем
if (responseSearch.isEmpty() && currentQuery.isEmpty()) {
Log.d(TAG, "📥 Ignoring empty search response - no active search")
return@handler
}
// Если search не пустой и не совпадает с нашим запросом - игнорируем
if (responseSearch.isNotEmpty() && responseSearch != currentQuery) {
Log.d(TAG, "📥 Ignoring search response - search mismatch: '$responseSearch' != '$currentQuery'")
return@handler
}
Log.d(TAG, "📥 ACCEPTED PacketSearch response: ${packet.users.size} users")
packet.users.forEachIndexed { index, user -> packet.users.forEachIndexed { index, user ->
Log.d(TAG, " [$index] publicKey=${user.publicKey.take(16)}... title=${user.title} username=${user.username}")
} }
_searchResults.value = packet.users _searchResults.value = packet.users
_isSearching.value = false _isSearching.value = false
Log.d(TAG, "📥 Updated searchResults, isSearching=false")
} }
} }
init { init {
// Регистрируем обработчик пакетов поиска // Регистрируем обработчик пакетов поиска
Log.d(TAG, "🟢 INIT: Registering searchPacketHandler for 0x03")
ProtocolManager.waitPacket(0x03, searchPacketHandler) ProtocolManager.waitPacket(0x03, searchPacketHandler)
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
// Отписываемся от пакетов при уничтожении ViewModel // Отписываемся от пакетов при уничтожении ViewModel
Log.d(TAG, "🔴 onCleared: Unregistering searchPacketHandler")
ProtocolManager.unwaitPacket(0x03, searchPacketHandler) ProtocolManager.unwaitPacket(0x03, searchPacketHandler)
searchJob?.cancel() searchJob?.cancel()
} }
@@ -71,6 +101,7 @@ class SearchUsersViewModel : ViewModel() {
* Аналогично handleSearch в React Native * Аналогично handleSearch в React Native
*/ */
fun onSearchQueryChange(query: String) { fun onSearchQueryChange(query: String) {
Log.d(TAG, "🔍 onSearchQueryChange: query='$query' lastSearchedText='$lastSearchedText'")
_searchQuery.value = query _searchQuery.value = query
// Отменяем предыдущий поиск // Отменяем предыдущий поиск
@@ -78,6 +109,7 @@ class SearchUsersViewModel : ViewModel() {
// Если пустой запрос - очищаем результаты // Если пустой запрос - очищаем результаты
if (query.trim().isEmpty()) { if (query.trim().isEmpty()) {
Log.d(TAG, "🔍 Empty query, clearing results")
_searchResults.value = emptyList() _searchResults.value = emptyList()
_isSearching.value = false _isSearching.value = false
lastSearchedText = "" lastSearchedText = ""
@@ -86,29 +118,36 @@ class SearchUsersViewModel : ViewModel() {
// Если текст уже был найден - не повторяем поиск // Если текст уже был найден - не повторяем поиск
if (query == lastSearchedText) { if (query == lastSearchedText) {
Log.d(TAG, "🔍 Query same as lastSearchedText, skipping")
return return
} }
// Показываем индикатор загрузки // Показываем индикатор загрузки
_isSearching.value = true _isSearching.value = true
Log.d(TAG, "🔍 Starting search job with 1s debounce")
// Запускаем поиск с задержкой 1 секунда (как в React Native) // Запускаем поиск с задержкой 1 секунда (как в React Native)
searchJob = viewModelScope.launch { searchJob = viewModelScope.launch {
delay(1000) // debounce delay(1000) // debounce
Log.d(TAG, "🔍 After debounce: protocolState=${ProtocolManager.state.value}")
// Проверяем состояние протокола // Проверяем состояние протокола
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) { if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
Log.d(TAG, "🔍 Protocol not authenticated, aborting")
_isSearching.value = false _isSearching.value = false
return@launch return@launch
} }
// Проверяем, не изменился ли запрос // Проверяем, не изменился ли запрос
if (query != _searchQuery.value) { if (query != _searchQuery.value) {
Log.d(TAG, "🔍 Query changed during debounce, aborting")
return@launch return@launch
} }
lastSearchedText = query lastSearchedText = query
Log.d(TAG, "📤 SENDING PacketSearch: query='$query' privateKeyHash=${privateKeyHash.take(16)}...")
// Создаем и отправляем пакет поиска // Создаем и отправляем пакет поиска
val packetSearch = PacketSearch().apply { val packetSearch = PacketSearch().apply {
@@ -117,6 +156,7 @@ class SearchUsersViewModel : ViewModel() {
} }
ProtocolManager.sendPacket(packetSearch) ProtocolManager.sendPacket(packetSearch)
Log.d(TAG, "📤 PacketSearch sent!")
} }
} }