feat: Implement special handling for Saved Messages, including dedicated methods for retrieval, display, and dialog updates
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user