feat: Add message count query to MessageDao, enhance ChatDetailScreen with auto-focus on reply input, and improve read receipt handling in ChatViewModel

This commit is contained in:
2026-01-13 07:02:01 +05:00
parent 378c68f1eb
commit eb8d24a782
5 changed files with 161 additions and 57 deletions

View File

@@ -145,6 +145,15 @@ interface MessageDao {
""") """)
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>> fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
/**
* Получить количество сообщений в диалоге
*/
@Query("""
SELECT COUNT(*) FROM messages
WHERE account = :account AND dialog_key = :dialogKey
""")
suspend fun getMessageCount(account: String, dialogKey: String): Int
/** /**
* Получить последние N сообщений диалога * Получить последние N сообщений диалога
*/ */

View File

@@ -240,6 +240,17 @@ fun ChatDetailScreen(
// 🔥 FocusRequester для автофокуса на инпут при reply // 🔥 FocusRequester для автофокуса на инпут при reply
val inputFocusRequester = remember { FocusRequester() } val inputFocusRequester = remember { FocusRequester() }
// 🔥 Автофокус на инпут при появлении reply панели
LaunchedEffect(hasReply) {
if (hasReply) {
try {
inputFocusRequester.requestFocus()
} catch (e: Exception) {
// Игнорируем если фокус не удался
}
}
}
// 🔥 Дополнительная высота для reply панели (~50dp) // 🔥 Дополнительная высота для reply панели (~50dp)
val replyPanelHeight = if (hasReply) 50.dp else 0.dp val replyPanelHeight = if (hasReply) 50.dp else 0.dp
@@ -2041,7 +2052,8 @@ private fun MessageInputBar(
textSize = 16f, textSize = 16f,
hint = "Type message...", hint = "Type message...",
hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93), hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
requestFocus = hasReply
) )
} }

View File

@@ -105,8 +105,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private var lastTypingSentTime = 0L private var lastTypingSentTime = 0L
private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек
// Отслеживание прочитанных сообщений // Отслеживание прочитанных сообщений - храним timestamp последнего прочитанного
private val sentReadReceipts = mutableSetOf<String>() private var lastReadMessageTimestamp = 0L
// Флаг что read receipt уже отправлен для текущего диалога
private var readReceiptSentForCurrentDialog = false
init { init {
setupPacketListeners() setupPacketListeners()
@@ -116,10 +118,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Входящие сообщения // Входящие сообщения
ProtocolManager.waitPacket(0x06) { packet -> ProtocolManager.waitPacket(0x06) { packet ->
val msgPacket = packet as PacketMessage 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) { if (msgPacket.fromPublicKey == opponentKey || msgPacket.toPublicKey == opponentKey) {
ProtocolManager.addLog("📨 ✅ Match! Processing message...")
viewModelScope.launch { viewModelScope.launch {
handleIncomingMessage(msgPacket) handleIncomingMessage(msgPacket)
} }
} else {
ProtocolManager.addLog("📨 ❌ No match, ignoring")
} }
} }
@@ -199,6 +206,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val privateKey = myPrivateKey ?: return@launch val privateKey = myPrivateKey ?: return@launch
val account = myPublicKey ?: return@launch
ProtocolManager.addLog("📩 Incoming message: ${packet.messageId.take(8)}...") ProtocolManager.addLog("📩 Incoming message: ${packet.messageId.take(8)}...")
ProtocolManager.addLog("📎 Attachments count: ${packet.attachments.size}") ProtocolManager.addLog("📎 Attachments count: ${packet.attachments.size}")
@@ -269,7 +277,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
jsonArray.toString() jsonArray.toString()
} else "[]" } else "[]"
// Обновляем UI в Main потоке // Обновляем UI в Main потоке (как в архиве - просто добавляем без лишних проверок)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val message = ChatMessage( val message = ChatMessage(
id = packet.messageId, id = packet.messageId,
@@ -277,12 +285,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = packet.fromPublicKey == myPublicKey, isOutgoing = packet.fromPublicKey == myPublicKey,
timestamp = Date(packet.timestamp), timestamp = Date(packet.timestamp),
status = MessageStatus.DELIVERED, status = MessageStatus.DELIVERED,
replyData = replyData // 🔥 Добавляем reply данные replyData = replyData
) )
// Просто добавляем как в архиве: setMessages((prev) => ([...prev, newMessage]))
_messages.value = _messages.value + message _messages.value = _messages.value + message
ProtocolManager.addLog("✅ Added to UI: ${packet.messageId.take(8)}... text: ${decryptedText.take(20)}")
} }
// Сохраняем в БД (уже в IO потоке) // Сохраняем в БД (INSERT OR IGNORE - не будет дублей)
saveMessageToDatabase( saveMessageToDatabase(
messageId = packet.messageId, messageId = packet.messageId,
text = decryptedText, text = decryptedText,
@@ -300,10 +310,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// ⚠️ Delivery отправляется в ProtocolManager.setupPacketHandlers() // ⚠️ Delivery отправляется в ProtocolManager.setupPacketHandlers()
// Не отправляем повторно чтобы избежать дублирования! // Не отправляем повторно чтобы избежать дублирования!
// 👁️ Сразу отправляем read receipt (как в Telegram - сообщения прочитаны если чат открыт) // 👁️ Отмечаем сообщение как прочитанное в БД
messageDao.markAsRead(account, packet.messageId)
// 👁️ Отправляем read receipt собеседнику (как в архиве - сразу при получении)
delay(100) // Небольшая задержка для естественности delay(100) // Небольшая задержка для естественности
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
sendReadReceipt(packet.messageId, packet.fromPublicKey) // Обновляем timestamp и отправляем read receipt
lastReadMessageTimestamp = packet.timestamp
sendReadReceiptToOpponent()
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -364,7 +379,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
currentOffset = 0 currentOffset = 0
hasMoreMessages = true hasMoreMessages = true
isLoadingMessages = false isLoadingMessages = false
sentReadReceipts.clear() lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...") ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...")
@@ -394,10 +410,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val dialogKey = getDialogKey(account, opponent) val dialogKey = getDialogKey(account, opponent)
ProtocolManager.addLog("📂 Loading messages from DB for dialog: $dialogKey") 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) 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 hasMoreMessages = entities.size >= PAGE_SIZE
currentOffset = entities.size currentOffset = entities.size
@@ -407,10 +427,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
entityToChatMessage(entity) entityToChatMessage(entity)
}.reversed() }.reversed()
// 🔥 Отмечаем все входящие сообщения как прочитанные в БД (как в архиве)
messageDao.markDialogAsRead(account, dialogKey)
// 🔥 Очищаем счетчик непрочитанных в диалоге
dialogDao.clearUnreadCount(account, opponent)
ProtocolManager.addLog("👁️ Marked all incoming messages as read in DB, cleared unread count")
// Обновляем UI в Main потоке // Обновляем UI в Main потоке
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_messages.value = messages _messages.value = messages
_isLoading.value = false _isLoading.value = false
// 🔥 Отправляем read receipt собеседнику (как в архиве)
if (messages.isNotEmpty()) {
val lastIncoming = messages.lastOrNull { !it.isOutgoing }
if (lastIncoming != null && lastIncoming.timestamp.time > lastReadMessageTimestamp) {
sendReadReceiptToOpponent()
}
}
} }
isLoadingMessages = false isLoadingMessages = false
@@ -907,6 +941,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
try { try {
val dialogKey = getDialogKey(account, opponent) 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( val entity = MessageEntity(
account = account, account = account,
fromPublicKey = if (isFromMe) account else opponent, fromPublicKey = if (isFromMe) account else opponent,
@@ -924,8 +963,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogKey = dialogKey dialogKey = dialogKey
) )
messageDao.insertMessage(entity) val insertedId = messageDao.insertMessage(entity)
ProtocolManager.addLog("💾 Message saved to DB: ${messageId.take(8)}...") ProtocolManager.addLog(" Message saved with DB id: $insertedId")
} catch (e: Exception) { } catch (e: Exception) {
ProtocolManager.addLog("❌ Message save error: ${e.message}") ProtocolManager.addLog("❌ Message save error: ${e.message}")
@@ -989,18 +1028,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
/** /**
* 👁️ Отправить подтверждение о прочтении сообщения * 👁️ Отправить read receipt собеседнику
* В Desktop PacketRead не содержит messageId - он просто сообщает что мы прочитали сообщения * Как в архиве - просто отправляем PacketRead без messageId
* Означает что мы прочитали все сообщения от этого собеседника
*/ */
fun sendReadReceipt(messageId: String, senderPublicKey: String) { private fun sendReadReceiptToOpponent() {
// Не отправляем повторно для этого собеседника val opponent = opponentKey ?: return
val receiptKey = senderPublicKey
if (sentReadReceipts.contains(receiptKey)) return
val sender = myPublicKey ?: return val sender = myPublicKey ?: return
val privateKey = myPrivateKey ?: 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) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -1010,14 +1051,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val packet = PacketRead().apply { val packet = PacketRead().apply {
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
fromPublicKey = sender // Мы (кто прочитал) fromPublicKey = sender // Мы (кто прочитал)
toPublicKey = senderPublicKey // Кому отправляем уведомление toPublicKey = opponent // Кому отправляем уведомление (собеседник)
} }
ProtocolManager.send(packet) ProtocolManager.send(packet)
ProtocolManager.addLog("👁️ Read receipt sent to: ${senderPublicKey.take(8)}...") ProtocolManager.addLog("👁️ Read receipt sent to: ${opponent.take(8)}...")
readReceiptSentForCurrentDialog = true
// Обновляем в БД что сообщение прочитано
updateMessageReadInDb(messageId)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Read receipt send error", e) Log.e(TAG, "Read receipt send error", e)
} }
@@ -1025,18 +1064,36 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
/** /**
* 👁️ Отметить все непрочитанные входящие сообщения как прочитанные * 👁️ Публичный метод для отправки read receipt (вызывается из ChatDetailScreen)
* Теперь работает как в архиве - при изменении списка сообщений
*/ */
fun markVisibleMessagesAsRead() { fun markVisibleMessagesAsRead() {
val opponent = opponentKey ?: return val opponent = opponentKey ?: return
val account = myPublicKey ?: return
viewModelScope.launch { // Находим последнее входящее сообщение
_messages.value val lastIncoming = _messages.value.lastOrNull { !it.isOutgoing }
.filter { !it.isOutgoing && it.status != MessageStatus.READ } if (lastIncoming == null) return
.forEach { message ->
sendReadReceipt(message.id, opponent) // Если 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 fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
sentReadReceipts.clear() lastReadMessageTimestamp = 0L
readReceiptSentForCurrentDialog = false
opponentKey = null opponentKey = null
} }
} }

View File

@@ -877,30 +877,38 @@ fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit)
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 🔥 Используем AppleEmojiText для отображения эмодзи // 🔥 Используем AppleEmojiText для отображения эмодзи
// Если есть непрочитанные - текст темнее
AppleEmojiText( AppleEmojiText(
text = dialog.lastMessage.ifEmpty { "No messages" }, text = dialog.lastMessage.ifEmpty { "No messages" },
fontSize = 14.sp, 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) modifier = Modifier.weight(1f)
) )
// Unread badge // Unread badge
if (dialog.unreadCount > 0) { if (dialog.unreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
val unreadText = when {
dialog.unreadCount > 999 -> "999+"
dialog.unreadCount > 99 -> "99+"
else -> dialog.unreadCount.toString()
}
Box( Box(
modifier = modifier =
Modifier.size(22.dp) Modifier.height(22.dp)
.clip(CircleShape) .widthIn(min = 22.dp)
.background(PrimaryBlue), .clip(RoundedCornerShape(11.dp))
.background(PrimaryBlue)
.padding(horizontal = 6.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = text = unreadText,
if (dialog.unreadCount > 99) "99+"
else dialog.unreadCount.toString(),
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White,
maxLines = 1
) )
} }
} }

View File

@@ -207,8 +207,22 @@ fun AppleEmojiTextField(
textSize: Float = 16f, textSize: Float = 16f,
hint: String = "Message", hint: String = "Message",
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray, 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<AppleEmojiEditTextView?>(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( AndroidView(
factory = { ctx -> factory = { ctx ->
AppleEmojiEditTextView(ctx).apply { AppleEmojiEditTextView(ctx).apply {
@@ -220,6 +234,8 @@ fun AppleEmojiTextField(
// Убираем все возможные фоны у EditText // Убираем все возможные фоны у EditText
background = null background = null
setBackgroundColor(android.graphics.Color.TRANSPARENT) setBackgroundColor(android.graphics.Color.TRANSPARENT)
// Сохраняем ссылку на view
editTextView = this
// Уведомляем о создании view // Уведомляем о создании view
onViewCreated?.invoke(this) onViewCreated?.invoke(this)
} }
@@ -249,7 +265,8 @@ fun AppleEmojiText(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, 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 val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
else fontSize.value else fontSize.value
@@ -257,17 +274,29 @@ fun AppleEmojiText(
// Минимальная высота для корректного отображения emoji // Минимальная высота для корректного отображения emoji
val minHeight = (fontSizeValue * 1.5).toInt() 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( AndroidView(
factory = { ctx -> factory = { ctx ->
AppleEmojiTextView(ctx).apply { AppleEmojiTextView(ctx).apply {
setTextColor(color.toArgb()) setTextColor(color.toArgb())
setTextSize(fontSizeValue) setTextSize(fontSizeValue)
minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt() minimumHeight = (minHeight * ctx.resources.displayMetrics.density).toInt()
setTypeface(typeface, typefaceStyle)
} }
}, },
update = { view -> update = { view ->
view.setTextWithEmojis(text) view.setTextWithEmojis(text)
view.setTextColor(color.toArgb()) view.setTextColor(color.toArgb())
view.setTypeface(view.typeface, typefaceStyle)
}, },
modifier = modifier modifier = modifier
) )