diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index bb8ba7d..19b931c 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -158,7 +158,11 @@ class MessageRepository private constructor(private val context: Context) { val timestamp = System.currentTimeMillis() val dialogKey = getDialogKey(toPublicKey) + // 📁 Проверяем является ли это Saved Messages + val isSavedMessages = (account == toPublicKey) + // 1. Создаем оптимистичное сообщение + // 📁 Для saved messages - сразу DELIVERED и прочитано val optimisticMessage = Message( messageId = messageId, fromPublicKey = account, @@ -166,8 +170,8 @@ class MessageRepository private constructor(private val context: Context) { content = text.trim(), timestamp = timestamp, isFromMe = true, - isRead = account == toPublicKey, // Если сам себе - сразу прочитано - deliveryStatus = DeliveryStatus.WAITING, + isRead = isSavedMessages, // 📁 Если сам себе - сразу прочитано + deliveryStatus = if (isSavedMessages) DeliveryStatus.DELIVERED else DeliveryStatus.WAITING, // 📁 Для saved messages - сразу доставлено attachments = attachments, replyToMessageId = replyToMessageId ) @@ -194,6 +198,7 @@ class MessageRepository private constructor(private val context: Context) { val exists = messageDao.messageExists(account, messageId) if (!exists) { // Сохраняем в БД только если сообщения нет + // 📁 Для saved messages - сразу read=1 и delivered=DELIVERED val entity = MessageEntity( account = account, fromPublicKey = account, @@ -201,9 +206,9 @@ class MessageRepository private constructor(private val context: Context) { content = encryptedContent, timestamp = timestamp, chachaKey = encryptedKey, - read = if (account == toPublicKey) 1 else 0, + read = if (isSavedMessages) 1 else 0, fromMe = 1, - delivered = DeliveryStatus.WAITING.value, + delivered = if (isSavedMessages) DeliveryStatus.DELIVERED.value else DeliveryStatus.WAITING.value, messageId = messageId, plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст attachments = attachmentsJson, @@ -219,7 +224,13 @@ class MessageRepository private constructor(private val context: Context) { // 🔥 Отмечаем что я отправлял сообщения в этот диалог (перемещает из requests в chats) val updatedRows = dialogDao.markIHaveSent(account, toPublicKey) - // Отправляем пакет + // 📁 НЕ отправляем пакет на сервер для saved messages! + // Как в Архиве: if(publicKey == opponentPublicKey) return; + if (isSavedMessages) { + return@launch // Для saved messages - только локальное сохранение, без отправки на сервер + } + + // Отправляем пакет (только для обычных диалогов) val packet = PacketMessage().apply { this.fromPublicKey = account this.toPublicKey = toPublicKey @@ -375,6 +386,7 @@ class MessageRepository private constructor(private val context: Context) { /** * Отметить диалог как прочитанный * 🔥 После обновления messages обновляем диалог через updateDialogFromMessages + * 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ suspend fun markDialogAsRead(opponentKey: String) { val account = currentAccount ?: return @@ -385,17 +397,28 @@ class MessageRepository private constructor(private val context: Context) { // 🔥 КРИТИЧНО: Пересчитываем счетчики из таблицы messages // чтобы 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) { val account = currentAccount ?: return val privateKey = currentPrivateKey ?: return + // 📁 Для Saved Messages - не отправляем typing + if (account == toPublicKey) { + return + } + scope.launch { val packet = PacketTyping().apply { this.fromPublicKey = account @@ -435,8 +458,17 @@ class MessageRepository private constructor(private val context: Context) { // Private helpers // =============================== + /** + * Получить ключ диалога для группировки сообщений + * 📁 SAVED MESSAGES: Для saved messages (account == opponentKey) возвращает просто account + */ private fun getDialogKey(opponentKey: String): String { val account = currentAccount ?: return opponentKey + // Для saved messages dialog_key = просто publicKey + if (account == opponentKey) { + return account + } + // Для обычных диалогов - сортируем ключи return if (account < opponentKey) "$account:$opponentKey" else "$opponentKey:$account" } diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 2838fc2..07a01ad 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -138,6 +138,32 @@ interface MessageDao { """) suspend fun getMessages(account: String, dialogKey: String, limit: Int, offset: Int): List + /** + * 📁 Получить сообщения для 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 + + /** + * 📁 Получить количество сообщений в 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 */ @@ -508,4 +534,77 @@ interface DialogDao { ) """) 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) } diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 6edb63c..da9c087 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -174,24 +174,9 @@ object ProtocolManager { } } - // 🔥 Обработчик поиска (0x03) - обновляет информацию о пользователях в диалогах - waitPacket(0x03) { packet -> - val searchPacket = packet as PacketSearch - 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 - ) - } - } - } - } + // 🔥 УБРАН обработчик поиска (0x03) из ProtocolManager + // Он вызывал бесконечный цикл т.к. updateDialogUserInfo триггерил Flow + // Обработка 0x03 происходит только в SearchUsersViewModel } /** 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 9596148..536a9ac 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 @@ -171,13 +171,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * 🚀 Инкрементальное добавление последнего сообщения из БД * Вместо полной перезагрузки списка - добавляем только новое сообщение * Это предотвращает "прыгание" пузырьков в Compose + * 📁 SAVED MESSAGES: Использует специальные методы для saved messages */ private fun addLatestMessageFromDb(account: String, dialogKey: String) { viewModelScope.launch(Dispatchers.IO) { try { + // 📁 Проверяем является ли это Saved Messages + val opponent = opponentKey ?: return@launch + val isSavedMessages = (opponent == account) + // Получаем последнее сообщение из БД - val latestEntity = messageDao.getMessages(account, dialogKey, limit = 1, offset = 0).firstOrNull() - ?: return@launch + val latestEntity = if (isSavedMessages) { + 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() @@ -201,12 +209,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 👁️ Фоновые операции if (isDialogActive) { messageDao.markDialogAsRead(account, dialogKey) - // Отправляем read receipt - if (!newMessage.isOutgoing) { + // Отправляем read receipt (НЕ для saved messages!) + if (!newMessage.isOutgoing && !isSavedMessages) { sendReadReceiptToOpponent() } } - dialogDao.updateDialogFromMessages(account, opponentKey ?: return@launch) + + // Обновляем диалог - используем специальный метод для saved messages + if (isSavedMessages) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } } catch (e: Exception) { } @@ -379,12 +393,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * 🚀 СУПЕР-оптимизированная загрузка сообщений * 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка + * 📁 SAVED MESSAGES: Использует специальные методы для saved messages чтобы избежать дублирования */ private fun loadMessagesFromDatabase(delayMs: Long = 0L) { val account = myPublicKey ?: return val opponent = opponentKey ?: return val dialogKey = getDialogKey(account, opponent) + // 📁 Проверяем является ли это Saved Messages + val isSavedMessages = (opponent == account) + if (isLoadingMessages) return isLoadingMessages = true @@ -400,14 +418,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Фоновое обновление из БД (новые сообщения) delay(100) // Небольшая задержка чтобы UI успел отрисоваться - refreshMessagesFromDb(account, opponent, dialogKey, cachedMessages) + refreshMessagesFromDb(account, opponent, dialogKey, cachedMessages, isSavedMessages) isLoadingMessages = false return@launch } // 🔥 Нет кэша - проверяем есть ли вообще сообщения в БД // Если диалог пустой - не показываем скелетон! - val totalCount = messageDao.getMessageCount(account, dialogKey) + val totalCount = if (isSavedMessages) { + messageDao.getMessageCountForSavedDialog(account) + } else { + messageDao.getMessageCount(account, dialogKey) + } if (totalCount == 0) { // Пустой диалог - сразу показываем пустое состояние без скелетона @@ -428,8 +450,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } - // 🔥 Получаем первую страницу - БЕЗ suspend задержки - val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) + // 🔥 Получаем первую страницу - используем специальный метод для saved messages + 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 @@ -469,11 +495,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (isDialogActive) { messageDao.markDialogAsRead(account, dialogKey) } - // 🔥 Пересчитываем счетчики из messages - dialogDao.updateDialogFromMessages(account, opponent) + // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved messages + if (isSavedMessages) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } - // Отправляем read receipt собеседнику - if (messages.isNotEmpty()) { + // Отправляем read receipt собеседнику (НЕ для saved messages!) + if (!isSavedMessages && messages.isNotEmpty()) { val lastIncoming = messages.lastOrNull { !it.isOutgoing } if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) { sendReadReceiptToOpponent() @@ -496,15 +526,21 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * 🔥 Фоновое обновление сообщений из БД (проверка новых) * Вызывается когда кэш уже отображён, но нужно проверить есть ли новые сообщения * 🔥 ВАЖНО: НЕ заменяем все сообщения - только добавляем новые, сохраняя существующие! + * 📁 SAVED MESSAGES: Использует специальные методы для saved messages */ private suspend fun refreshMessagesFromDb( account: String, opponent: String, dialogKey: String, - cachedMessages: List + cachedMessages: List, + isSavedMessages: Boolean ) { 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 (может уже содержать новые сообщения) val currentMessages = _messages.value @@ -537,8 +573,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (isDialogActive) { messageDao.markDialogAsRead(account, dialogKey) } - // 🔥 Пересчитываем счетчики из messages - dialogDao.updateDialogFromMessages(account, opponent) + // 🔥 Пересчитываем счетчики из messages - используем специальный метод для saved messages + if (isSavedMessages) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } } catch (e: Exception) { } @@ -548,11 +588,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * 🚀 Загрузка следующей страницы (для бесконечной прокрутки) + * 📁 SAVED MESSAGES: Использует специальные методы для saved messages */ fun loadMoreMessages() { val account = myPublicKey ?: return val opponent = opponentKey ?: return + // 📁 Проверяем является ли это Saved Messages + val isSavedMessages = (opponent == account) + if (!hasMoreMessages || isLoadingMessages) return isLoadingMessages = true @@ -564,7 +608,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { 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 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 { + // Для saved messages dialog_key = просто publicKey + if (account == opponent) { + return account + } + // Для обычных диалогов - сортируем ключи return if (account < opponent) { "$account:$opponent" } else { @@ -1072,11 +1126,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { packet.attachments.forEachIndexed { idx, att -> } - // Отправляем пакет - ProtocolManager.send(packet) + // 📁 Для Saved Messages - НЕ отправляем пакет на сервер + // Только сохраняем локально + val isSavedMessages = (sender == recipient) + if (!isSavedMessages) { + // Отправляем пакет только для обычных диалогов + ProtocolManager.send(packet) + } // 3. 🎯 UI обновление в Main потоке withContext(Dispatchers.Main) { + // 📁 Для Saved Messages - сразу SENT, для обычных - ждём delivery updateMessageStatus(messageId, MessageStatus.SENT) } @@ -1102,7 +1162,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { encryptedKey = encryptedKey, timestamp = timestamp, isFromMe = true, - delivered = 0, // 🔥 SENDING - ждём PacketDelivery для DELIVERED + delivered = if (isSavedMessages) 2 else 0, // 📁 Saved Messages: сразу DELIVERED (2), иначе SENDING (0) attachmentsJson = attachmentsJson ) @@ -1120,7 +1180,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * Сохранить диалог в базу данных - * � Используем updateDialogFromMessages для пересчета счетчиков из messages + * 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages + * 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ private suspend fun saveDialog(lastMessage: String, timestamp: Long) { val account = myPublicKey ?: return @@ -1129,13 +1190,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { try { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // напрямую из таблицы messages, как в Архиве! - dialogDao.updateDialogFromMessages(account, opponent) + // 📁 Используем специальный метод для saved messages + if (opponent == account) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } } catch (e: Exception) { } } /** * Обновить диалог при входящем сообщении + * 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ private suspend fun updateDialog(opponentKey: String, lastMessage: String, timestamp: Long, incrementUnread: Boolean) { val account = myPublicKey ?: return @@ -1144,7 +1211,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // напрямую из таблицы messages, как в Архиве! // Это гарантирует что unread_count всегда соответствует реальному количеству непрочитанных - dialogDao.updateDialogFromMessages(account, opponentKey) + // 📁 Используем специальный метод для saved messages + if (opponentKey == account) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponentKey) + } } catch (e: Exception) { } } @@ -1225,6 +1297,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * 📝 Отправить индикатор "печатает..." * С throttling чтобы не спамить сервер + * 📁 Для Saved Messages - не отправляем (нельзя печатать самому себе) */ fun sendTypingIndicator() { val now = System.currentTimeMillis() @@ -1236,6 +1309,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val sender = myPublicKey ?: run { return } + + // 📁 Для Saved Messages - не отправляем typing indicator + if (opponent == sender) { + return + } + val privateKey = myPrivateKey ?: run { return } @@ -1263,6 +1342,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { * 👁️ Отправить read receipt собеседнику * Как в архиве - просто отправляем PacketRead без messageId * Означает что мы прочитали все сообщения от этого собеседника + * 📁 SAVED MESSAGES: НЕ отправляет read receipt для saved messages (нельзя слать самому себе) */ private fun sendReadReceiptToOpponent() { // 🔥 Не отправляем read receipt если диалог не активен (как в архиве) @@ -1272,6 +1352,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val opponent = opponentKey ?: return val sender = myPublicKey ?: return + + // 📁 НЕ отправляем read receipt для saved messages (opponent == sender) + if (opponent == sender) { + return + } + val privateKey = myPrivateKey ?: return // Обновляем timestamp последнего прочитанного @@ -1325,7 +1411,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val dialogKey = getDialogKey(account, opponent) messageDao.markDialogAsRead(account, dialogKey) // 🔥 Пересчитываем счетчики из messages - dialogDao.updateDialogFromMessages(account, opponent) + // 📁 Используем специальный метод для saved messages + if (opponent == account) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } } catch (e: Exception) { } } @@ -1336,10 +1427,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** * 🟢 Подписаться на онлайн статус собеседника + * 📁 Для Saved Messages - не подписываемся */ fun subscribeToOnlineStatus() { val opponent = opponentKey ?: return val privateKey = myPrivateKey ?: return + val account = myPublicKey ?: return + + // 📁 Для Saved Messages - не нужно подписываться на свой собственный статус + if (account == opponent) { + return + } viewModelScope.launch(Dispatchers.IO) { try { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 4c5a52c..419e15a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -1551,13 +1551,23 @@ fun DialogItemContent( remember(dialog.opponentKey, isDarkTheme) { getAvatarColor(dialog.opponentKey, isDarkTheme) } + + // 📁 Для Saved Messages показываем специальное имя val displayName = - remember(dialog.opponentTitle, dialog.opponentKey) { - dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } + remember(dialog.opponentTitle, dialog.opponentKey, dialog.isSavedMessages) { + if (dialog.isSavedMessages) { + "Saved Messages" + } else { + dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } + } } + + // 📁 Для Saved Messages показываем иконку закладки val initials = - remember(dialog.opponentTitle, dialog.opponentKey) { - if (dialog.opponentTitle.isNotEmpty()) { + remember(dialog.opponentTitle, dialog.opponentKey, dialog.isSavedMessages) { + if (dialog.isSavedMessages) { + "📁" // Иконка для Saved Messages + } else if (dialog.opponentTitle.isNotEmpty()) { dialog.opponentTitle .split(" ") .take(2) 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 f1a58f9..48591bf 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 @@ -29,7 +29,8 @@ data class DialogUiModel( val unreadCount: Int, val isOnline: Int, 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 currentPrivateKey: String? = null + // 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы + private val requestedUserInfoKeys = mutableSetOf() + // Список диалогов с расшифрованными сообщениями private val _dialogs = MutableStateFlow>(emptyList()) val dialogs: StateFlow> = _dialogs.asStateFlow() @@ -125,7 +129,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio unreadCount = dialog.unreadCount, isOnline = dialog.isOnline, 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 // 🟢 Подписываемся на онлайн-статусы всех собеседников - 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) .flowOn(Dispatchers.IO) .map { requestsList -> + android.util.Log.d("ChatsListVM", "📬 getRequestsFlow emitted: ${requestsList.size} requests") 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) } @@ -172,7 +185,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio unreadCount = dialog.unreadCount, isOnline = dialog.isOnline, 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 + * 📁 SAVED MESSAGES: Использует специальный метод для saved messages */ suspend fun upsertDialog( opponentKey: String, @@ -233,7 +248,12 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio try { // 🔥 КРИТИЧНО: Используем updateDialogFromMessages который пересчитывает счетчики // напрямую из таблицы messages, как в Архиве! - dialogDao.updateDialogFromMessages(currentAccount, opponentKey) + // 📁 Используем специальный метод для saved messages + if (opponentKey == currentAccount) { + dialogDao.updateSavedMessagesDialogFromMessages(currentAccount) + } else { + dialogDao.updateDialogFromMessages(currentAccount, opponentKey) + } // Обновляем информацию о собеседнике если есть if (opponentTitle.isNotEmpty()) { @@ -349,8 +369,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio /** * 📬 Загрузить информацию о пользователе для request + * 📁 НЕ загружаем для Saved Messages (свой publicKey) */ 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) { try { val sharedPrefs = getApplication().getSharedPreferences("rosetta", Application.MODE_PRIVATE) @@ -361,6 +397,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey) + android.util.Log.d("ChatsListVM", "📤 Sending PacketSearch for user info: ${publicKey.take(16)}...") + // Запрашиваем информацию о пользователе с сервера val packet = PacketSearch().apply { this.privateKey = privateKeyHash @@ -368,6 +406,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } ProtocolManager.send(packet) } catch (e: Exception) { + android.util.Log.e("ChatsListVM", "❌ loadUserInfoForRequest error: ${e.message}") } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt index d391b50..98a7ed3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt @@ -1,5 +1,6 @@ package com.rosetta.messenger.ui.chats +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.network.PacketSearch @@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +private const val TAG = "SearchUsersVM" + /** * ViewModel для поиска пользователей через протокол * Работает аналогично SearchBar в React Native приложении @@ -38,23 +41,50 @@ class SearchUsersViewModel : ViewModel() { private var privateKeyHash: String = "" // 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) { + // 🔥 ВАЖНО: Игнорируем ответы с пустым 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 -> + Log.d(TAG, " [$index] publicKey=${user.publicKey.take(16)}... title=${user.title} username=${user.username}") } _searchResults.value = packet.users _isSearching.value = false + Log.d(TAG, "📥 Updated searchResults, isSearching=false") } } init { // Регистрируем обработчик пакетов поиска + Log.d(TAG, "🟢 INIT: Registering searchPacketHandler for 0x03") ProtocolManager.waitPacket(0x03, searchPacketHandler) } override fun onCleared() { super.onCleared() // Отписываемся от пакетов при уничтожении ViewModel + Log.d(TAG, "🔴 onCleared: Unregistering searchPacketHandler") ProtocolManager.unwaitPacket(0x03, searchPacketHandler) searchJob?.cancel() } @@ -71,6 +101,7 @@ class SearchUsersViewModel : ViewModel() { * Аналогично handleSearch в React Native */ fun onSearchQueryChange(query: String) { + Log.d(TAG, "🔍 onSearchQueryChange: query='$query' lastSearchedText='$lastSearchedText'") _searchQuery.value = query // Отменяем предыдущий поиск @@ -78,6 +109,7 @@ class SearchUsersViewModel : ViewModel() { // Если пустой запрос - очищаем результаты if (query.trim().isEmpty()) { + Log.d(TAG, "🔍 Empty query, clearing results") _searchResults.value = emptyList() _isSearching.value = false lastSearchedText = "" @@ -86,29 +118,36 @@ class SearchUsersViewModel : ViewModel() { // Если текст уже был найден - не повторяем поиск if (query == lastSearchedText) { + Log.d(TAG, "🔍 Query same as lastSearchedText, skipping") return } // Показываем индикатор загрузки _isSearching.value = true + Log.d(TAG, "🔍 Starting search job with 1s debounce") // Запускаем поиск с задержкой 1 секунда (как в React Native) searchJob = viewModelScope.launch { delay(1000) // debounce + Log.d(TAG, "🔍 After debounce: protocolState=${ProtocolManager.state.value}") + // Проверяем состояние протокола if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) { + Log.d(TAG, "🔍 Protocol not authenticated, aborting") _isSearching.value = false return@launch } // Проверяем, не изменился ли запрос if (query != _searchQuery.value) { + Log.d(TAG, "🔍 Query changed during debounce, aborting") return@launch } lastSearchedText = query + Log.d(TAG, "📤 SENDING PacketSearch: query='$query' privateKeyHash=${privateKeyHash.take(16)}...") // Создаем и отправляем пакет поиска val packetSearch = PacketSearch().apply { @@ -117,6 +156,7 @@ class SearchUsersViewModel : ViewModel() { } ProtocolManager.sendPacket(packetSearch) + Log.d(TAG, "📤 PacketSearch sent!") } }