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:
@@ -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 сообщений диалога
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user