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 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"
}

View File

@@ -138,6 +138,32 @@ interface MessageDao {
""")
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
*/
@@ -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)
}

View File

@@ -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
}
/**

View File

@@ -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<ChatMessage>
cachedMessages: List<ChatMessage>,
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) {
/**
* Сохранить диалог в базу данных
* <EFBFBD> Используем 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 {

View File

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

View File

@@ -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<String>()
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
val dialogs: StateFlow<List<DialogUiModel>> = _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<Application>().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}")
}
}
}

View File

@@ -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!")
}
}