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:
k1ngsterr1
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>>
/**
* Получить количество сообщений в диалоге
*/
@Query("""
SELECT COUNT(*) FROM messages
WHERE account = :account AND dialog_key = :dialogKey
""")
suspend fun getMessageCount(account: String, dialogKey: String): Int
/**
* Получить последние N сообщений диалога
*/

View File

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

View File

@@ -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<String>()
// Отслеживание прочитанных сообщений - храним 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,
@@ -300,10 +310,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// ⚠️ 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
}
}

View File

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

View File

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