From eb8d24a782d91710a258bac60d98f7306e5edb53 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 13 Jan 2026 07:02:01 +0500 Subject: [PATCH] feat: Add message count query to MessageDao, enhance ChatDetailScreen with auto-focus on reply input, and improve read receipt handling in ChatViewModel --- .../messenger/database/MessageEntities.kt | 9 ++ .../messenger/ui/chats/ChatDetailScreen.kt | 14 +- .../messenger/ui/chats/ChatViewModel.kt | 136 ++++++++++++------ .../messenger/ui/chats/ChatsListScreen.kt | 26 ++-- .../ui/components/AppleEmojiEditText.kt | 33 ++++- 5 files changed, 161 insertions(+), 57 deletions(-) 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 909fc4f..9a3ac8e 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -145,6 +145,15 @@ interface MessageDao { """) fun getMessagesFlow(account: String, dialogKey: String): Flow> + /** + * Получить количество сообщений в диалоге + */ + @Query(""" + SELECT COUNT(*) FROM messages + WHERE account = :account AND dialog_key = :dialogKey + """) + suspend fun getMessageCount(account: String, dialogKey: String): Int + /** * Получить последние N сообщений диалога */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 9748ec1..968c9e9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -240,6 +240,17 @@ fun ChatDetailScreen( // 🔥 FocusRequester для автофокуса на инпут при reply val inputFocusRequester = remember { FocusRequester() } + // 🔥 Автофокус на инпут при появлении reply панели + LaunchedEffect(hasReply) { + if (hasReply) { + try { + inputFocusRequester.requestFocus() + } catch (e: Exception) { + // Игнорируем если фокус не удался + } + } + } + // 🔥 Дополнительная высота для reply панели (~50dp) val replyPanelHeight = if (hasReply) 50.dp else 0.dp @@ -2041,7 +2052,8 @@ private fun MessageInputBar( textSize = 16f, hint = "Type message...", hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + requestFocus = hasReply ) } 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 88edb2c..18d3c9d 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 @@ -105,8 +105,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private var lastTypingSentTime = 0L private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек - // Отслеживание прочитанных сообщений - private val sentReadReceipts = mutableSetOf() + // Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного + private var lastReadMessageTimestamp = 0L + // Флаг что read receipt уже отправлен для текущего диалога + private var readReceiptSentForCurrentDialog = false init { setupPacketListeners() @@ -116,10 +118,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Входящие сообщения ProtocolManager.waitPacket(0x06) { packet -> val msgPacket = packet as PacketMessage + ProtocolManager.addLog("📨 ChatVM got packet 0x06: from=${msgPacket.fromPublicKey.take(16)}, to=${msgPacket.toPublicKey.take(16)}") + ProtocolManager.addLog("📨 opponentKey=${opponentKey?.take(16) ?: "NULL"}") if (msgPacket.fromPublicKey == opponentKey || msgPacket.toPublicKey == opponentKey) { + ProtocolManager.addLog("📨 ✅ Match! Processing message...") viewModelScope.launch { handleIncomingMessage(msgPacket) } + } else { + ProtocolManager.addLog("📨 ❌ No match, ignoring") } } @@ -199,6 +206,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch(Dispatchers.IO) { try { val privateKey = myPrivateKey ?: return@launch + val account = myPublicKey ?: return@launch ProtocolManager.addLog("📩 Incoming message: ${packet.messageId.take(8)}...") ProtocolManager.addLog("📎 Attachments count: ${packet.attachments.size}") @@ -269,7 +277,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { jsonArray.toString() } else "[]" - // Обновляем UI в Main потоке + // Обновляем UI в Main потоке (как в архиве - просто добавляем без лишних проверок) withContext(Dispatchers.Main) { val message = ChatMessage( id = packet.messageId, @@ -277,12 +285,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isOutgoing = packet.fromPublicKey == myPublicKey, timestamp = Date(packet.timestamp), status = MessageStatus.DELIVERED, - replyData = replyData // 🔥 Добавляем reply данные + replyData = replyData ) + // Просто добавляем как в архиве: setMessages((prev) => ([...prev, newMessage])) _messages.value = _messages.value + message + ProtocolManager.addLog("✅ Added to UI: ${packet.messageId.take(8)}... text: ${decryptedText.take(20)}") } - // Сохраняем в БД (уже в IO потоке) + // Сохраняем в БД (INSERT OR IGNORE - не будет дублей) saveMessageToDatabase( messageId = packet.messageId, text = decryptedText, @@ -293,17 +303,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { delivered = 1, attachmentsJson = attachmentsJson // 🔥 Сохраняем attachments ) - + // Обновляем диалог saveDialog(decryptedText, packet.timestamp) // ⚠️ Delivery отправляется в ProtocolManager.setupPacketHandlers() // Не отправляем повторно чтобы избежать дублирования! - // 👁️ Сразу отправляем read receipt (как в Telegram - сообщения прочитаны если чат открыт) + // 👁️ Отмечаем сообщение как прочитанное в БД + messageDao.markAsRead(account, packet.messageId) + + // 👁️ Отправляем read receipt собеседнику (как в архиве - сразу при получении) delay(100) // Небольшая задержка для естественности withContext(Dispatchers.Main) { - sendReadReceipt(packet.messageId, packet.fromPublicKey) + // Обновляем timestamp и отправляем read receipt + lastReadMessageTimestamp = packet.timestamp + sendReadReceiptToOpponent() } } catch (e: Exception) { @@ -364,7 +379,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { currentOffset = 0 hasMoreMessages = true isLoadingMessages = false - sentReadReceipts.clear() + lastReadMessageTimestamp = 0L + readReceiptSentForCurrentDialog = false ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...") @@ -394,10 +410,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val dialogKey = getDialogKey(account, opponent) ProtocolManager.addLog("📂 Loading messages from DB for dialog: $dialogKey") + // 🔍 Проверяем общее количество сообщений в диалоге + val totalCount = messageDao.getMessageCount(account, dialogKey) + ProtocolManager.addLog("📂 Total messages in DB: $totalCount") + // Получаем первую страницу сообщений val entities = messageDao.getMessages(account, dialogKey, limit = PAGE_SIZE, offset = 0) - ProtocolManager.addLog("📂 Loaded ${entities.size} messages from DB") + ProtocolManager.addLog("📂 Loaded ${entities.size} messages from DB (offset: 0, limit: $PAGE_SIZE)") hasMoreMessages = entities.size >= PAGE_SIZE currentOffset = entities.size @@ -407,10 +427,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { entityToChatMessage(entity) }.reversed() + // 🔥 Отмечаем все входящие сообщения как прочитанные в БД (как в архиве) + messageDao.markDialogAsRead(account, dialogKey) + // 🔥 Очищаем счетчик непрочитанных в диалоге + dialogDao.clearUnreadCount(account, opponent) + ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count") + // Обновляем UI в Main потоке withContext(Dispatchers.Main) { _messages.value = messages _isLoading.value = false + + // 🔥 Отправляем read receipt собеседнику (как в архиве) + if (messages.isNotEmpty()) { + val lastIncoming = messages.lastOrNull { !it.isOutgoing } + if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) { + sendReadReceiptToOpponent() + } + } } isLoadingMessages = false @@ -907,6 +941,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { try { val dialogKey = getDialogKey(account, opponent) + ProtocolManager.addLog("💾 Saving message to DB:") + ProtocolManager.addLog(" messageId: ${messageId.take(8)}...") + ProtocolManager.addLog(" dialogKey: $dialogKey") + ProtocolManager.addLog(" text: ${text.take(20)}...") + val entity = MessageEntity( account = account, fromPublicKey = if (isFromMe) account else opponent, @@ -924,8 +963,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { dialogKey = dialogKey ) - messageDao.insertMessage(entity) - ProtocolManager.addLog("💾 Message saved to DB: ${messageId.take(8)}...") + val insertedId = messageDao.insertMessage(entity) + ProtocolManager.addLog("✅ Message saved with DB id: $insertedId") } catch (e: Exception) { ProtocolManager.addLog("❌ Message save error: ${e.message}") @@ -989,18 +1028,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * 👁️ Отправить подтверждение о прочтении сообщения - * В Desktop PacketRead не содержит messageId - он просто сообщает что мы прочитали сообщения + * 👁️ Отправить read receipt собеседнику + * Как в архиве - просто отправляем PacketRead без messageId + * Означает что мы прочитали все сообщения от этого собеседника */ - fun sendReadReceipt(messageId: String, senderPublicKey: String) { - // Не отправляем повторно для этого собеседника - val receiptKey = senderPublicKey - if (sentReadReceipts.contains(receiptKey)) return - + private fun sendReadReceiptToOpponent() { + val opponent = opponentKey ?: return val sender = myPublicKey ?: return val privateKey = myPrivateKey ?: return - sentReadReceipts.add(receiptKey) + // Обновляем timestamp последнего прочитанного + val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing } + if (lastIncoming != null) { + lastReadMessageTimestamp = lastIncoming.timestamp.time + } viewModelScope.launch(Dispatchers.IO) { try { @@ -1010,14 +1051,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val packet = PacketRead().apply { this.privateKey = privateKeyHash fromPublicKey = sender // Мы (кто прочитал) - toPublicKey = senderPublicKey // Кому отправляем уведомление + toPublicKey = opponent // Кому отправляем уведомление (собеседник) } ProtocolManager.send(packet) - ProtocolManager.addLog("👁️ Read receipt sent to: ${senderPublicKey.take(8)}...") - - // Обновляем в БД что сообщение прочитано - updateMessageReadInDb(messageId) + ProtocolManager.addLog("👁️ Read receipt sent to: ${opponent.take(8)}...") + readReceiptSentForCurrentDialog = true } catch (e: Exception) { Log.e(TAG, "Read receipt send error", e) } @@ -1025,18 +1064,36 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } /** - * 👁️ Отметить все непрочитанные входящие сообщения как прочитанные + * 👁️ Публичный метод для отправки read receipt (вызывается из ChatDetailScreen) + * Теперь работает как в архиве - при изменении списка сообщений */ fun markVisibleMessagesAsRead() { val opponent = opponentKey ?: return + val account = myPublicKey ?: return - viewModelScope.launch { - _messages.value - .filter { !it.isOutgoing && it.status != MessageStatus.READ } - .forEach { message -> - sendReadReceipt(message.id, opponent) - } + // Находим последнее входящее сообщение + val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing } + if (lastIncoming == null) return + + // Если timestamp не изменился - не отправляем повторно + if (lastIncoming.timestamp.time <= lastReadMessageTimestamp) return + + ProtocolManager.addLog("👁️ markVisibleMessagesAsRead: new message detected") + + // Отмечаем в БД и очищаем счетчик непрочитанных + viewModelScope.launch(Dispatchers.IO) { + try { + val dialogKey = getDialogKey(account, opponent) + messageDao.markDialogAsRead(account, dialogKey) + dialogDao.clearUnreadCount(account, opponent) + ProtocolManager.addLog("👁️ Marked dialog as read in DB, cleared unread count") + } catch (e: Exception) { + Log.e(TAG, "Mark as read error", e) + } } + + // Отправляем read receipt + sendReadReceiptToOpponent() } /** @@ -1064,23 +1121,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - /** - * Обновить статус прочтения в БД - */ - private suspend fun updateMessageReadInDb(messageId: String) { - try { - val account = myPublicKey ?: return - messageDao.markAsRead(account, messageId) - } catch (e: Exception) { - Log.e(TAG, "Update read status error", e) - } - } - fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending override fun onCleared() { super.onCleared() - sentReadReceipts.clear() + lastReadMessageTimestamp = 0L + readReceiptSentForCurrentDialog = false opponentKey = null } } 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 44794be..910a0f5 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 @@ -877,30 +877,38 @@ fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit) verticalAlignment = Alignment.CenterVertically ) { // 🔥 Используем AppleEmojiText для отображения эмодзи + // Если есть непрочитанные - текст темнее AppleEmojiText( text = dialog.lastMessage.ifEmpty { "No messages" }, fontSize = 14.sp, - color = secondaryTextColor, + color = if (dialog.unreadCount > 0) textColor.copy(alpha = 0.85f) else secondaryTextColor, + fontWeight = if (dialog.unreadCount > 0) FontWeight.Medium else FontWeight.Normal, modifier = Modifier.weight(1f) ) // Unread badge if (dialog.unreadCount > 0) { Spacer(modifier = Modifier.width(8.dp)) + val unreadText = when { + dialog.unreadCount > 999 -> "999+" + dialog.unreadCount > 99 -> "99+" + else -> dialog.unreadCount.toString() + } Box( modifier = - Modifier.size(22.dp) - .clip(CircleShape) - .background(PrimaryBlue), + Modifier.height(22.dp) + .widthIn(min = 22.dp) + .clip(RoundedCornerShape(11.dp)) + .background(PrimaryBlue) + .padding(horizontal = 6.dp), contentAlignment = Alignment.Center ) { Text( - text = - if (dialog.unreadCount > 99) "99+" - else dialog.unreadCount.toString(), + text = unreadText, fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1 ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 17d49ae..8487bb7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -207,8 +207,22 @@ fun AppleEmojiTextField( textSize: Float = 16f, hint: String = "Message", hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray, - onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null + onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null, + requestFocus: Boolean = false ) { + // Храним ссылку на view для управления фокусом + var editTextView by remember { mutableStateOf(null) } + + // 🔥 Автоматический запрос фокуса когда requestFocus = true + LaunchedEffect(requestFocus) { + if (requestFocus && editTextView != null) { + editTextView?.requestFocus() + // Показываем клавиатуру + val imm = editTextView?.context?.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager + imm?.showSoftInput(editTextView, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) + } + } + AndroidView( factory = { ctx -> AppleEmojiEditTextView(ctx).apply { @@ -220,6 +234,8 @@ fun AppleEmojiTextField( // Убираем все возможные фоны у EditText background = null setBackgroundColor(android.graphics.Color.TRANSPARENT) + // Сохраняем ссылку на view + editTextView = this // Уведомляем о создании view onViewCreated?.invoke(this) } @@ -249,7 +265,8 @@ fun AppleEmojiText( text: String, modifier: Modifier = Modifier, color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, - fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified + fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified, + fontWeight: androidx.compose.ui.text.font.FontWeight? = null ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f else fontSize.value @@ -257,17 +274,29 @@ fun AppleEmojiText( // Минимальная высота для корректного отображения emoji val minHeight = (fontSizeValue * 1.5).toInt() + // Преобразуем FontWeight в Android typeface style + val typefaceStyle = when (fontWeight) { + androidx.compose.ui.text.font.FontWeight.Bold, + androidx.compose.ui.text.font.FontWeight.ExtraBold, + androidx.compose.ui.text.font.FontWeight.Black, + androidx.compose.ui.text.font.FontWeight.SemiBold -> android.graphics.Typeface.BOLD + androidx.compose.ui.text.font.FontWeight.Medium -> android.graphics.Typeface.NORMAL // Medium не поддерживается напрямую + else -> android.graphics.Typeface.NORMAL + } + AndroidView( factory = { ctx -> AppleEmojiTextView(ctx).apply { setTextColor(color.toArgb()) setTextSize(fontSizeValue) minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt() + setTypeface(typeface, typefaceStyle) } }, update = { view -> view.setTextWithEmojis(text) view.setTextColor(color.toArgb()) + view.setTypeface(view.typeface, typefaceStyle) }, modifier = modifier )