android: add mention override for muted chat notifications
Some checks failed
CI / test (push) Failing after 2m18s

This commit is contained in:
Codex
2026-03-09 14:52:28 +03:00
parent 98492f869d
commit 670fcd721d
8 changed files with 60 additions and 2 deletions

View File

@@ -295,3 +295,9 @@
- Wired realtime receive-message handling to trigger local notification via `NotificationDispatcher` when chat is not active. - Wired realtime receive-message handling to trigger local notification via `NotificationDispatcher` when chat is not active.
- Added chat title lookup helper in `ChatDao` for notification titles. - Added chat title lookup helper in `ChatDao` for notification titles.
- Added explicit realtime stop in `ChatViewModel.onCleared()` to avoid stale collectors. - Added explicit realtime stop in `ChatViewModel.onCleared()` to avoid stale collectors.
### Step 49 - Mention override for muted chats
- Extended realtime receive-message model/parsing with `isMention` flag support.
- Added muted-chat guard in realtime notification flow: muted chats stay silent unless message is a mention.
- Routed mention notifications to mentions channel/priority via `NotificationDispatcher`.
- Added parser unit test for mention-flag mapping.

View File

@@ -84,6 +84,9 @@ interface ChatDao {
@Query("SELECT display_title FROM chats WHERE id = :chatId LIMIT 1") @Query("SELECT display_title FROM chats WHERE id = :chatId LIMIT 1")
suspend fun getChatDisplayTitle(chatId: Long): String? suspend fun getChatDisplayTitle(chatId: Long): String?
@Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1")
suspend fun isChatMuted(chatId: Long): Boolean?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChats(chats: List<ChatEntity>) suspend fun upsertChats(chats: List<ChatEntity>)

View File

@@ -37,6 +37,11 @@ class RealtimeEventParser @Inject constructor(
text = messageObject?.get("text").stringOrNull(), text = messageObject?.get("text").stringOrNull(),
type = messageObject?.get("type").stringOrNull(), type = messageObject?.get("type").stringOrNull(),
createdAt = messageObject?.get("created_at").stringOrNull(), createdAt = messageObject?.get("created_at").stringOrNull(),
isMention = messageObject?.get("is_mention").boolOrNull()
?: payload["is_mention"].boolOrNull()
?: messageObject?.get("mentions_me").boolOrNull()
?: payload["mentions_me"].boolOrNull()
?: false,
) )
} }
@@ -119,4 +124,13 @@ class RealtimeEventParser @Inject constructor(
private fun JsonElement?.longOrNull(): Long? { private fun JsonElement?.longOrNull(): Long? {
return this?.jsonPrimitive?.contentOrNull?.toLongOrNull() return this?.jsonPrimitive?.contentOrNull?.toLongOrNull()
} }
private fun JsonElement?.boolOrNull(): Boolean? {
val raw = this?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return null
return when (raw) {
"true", "1" -> true
"false", "0" -> false
else -> null
}
}
} }

View File

@@ -11,6 +11,7 @@ sealed interface RealtimeEvent {
val text: String?, val text: String?,
val type: String?, val type: String?,
val createdAt: String?, val createdAt: String?,
val isMention: Boolean,
) : RealtimeEvent ) : RealtimeEvent
data class MessageUpdated( data class MessageUpdated(

View File

@@ -75,7 +75,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
) )
chatDao.incrementUnread(chatId = event.chatId) chatDao.incrementUnread(chatId = event.chatId)
val activeChatId = activeChatTracker.activeChatId.value val activeChatId = activeChatTracker.activeChatId.value
if (activeChatId != event.chatId) { val muted = chatDao.isChatMuted(event.chatId) == true
if (activeChatId != event.chatId && (!muted || event.isMention)) {
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message" val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
val body = event.text?.takeIf { it.isNotBlank() } val body = event.text?.takeIf { it.isNotBlank() }
?: when (event.type?.lowercase()) { ?: when (event.type?.lowercase()) {
@@ -92,6 +93,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
messageId = event.messageId, messageId = event.messageId,
title = title, title = title,
body = body, body = body,
isMention = event.isMention,
) )
) )
} }

View File

@@ -141,6 +141,10 @@ class NetworkChatRepositoryTest {
return chats.value.firstOrNull { it.id == chatId }?.displayTitle return chats.value.firstOrNull { it.id == chatId }?.displayTitle
} }
override suspend fun isChatMuted(chatId: Long): Boolean? {
return chats.value.firstOrNull { it.id == chatId }?.muted
}
override suspend fun upsertChats(chats: List<ChatEntity>) { override suspend fun upsertChats(chats: List<ChatEntity>) {
val merged = this.chats.value.associateBy { it.id }.toMutableMap() val merged = this.chats.value.associateBy { it.id }.toMutableMap()
chats.forEach { merged[it.id] = it } chats.forEach { merged[it.id] = it }

View File

@@ -2,6 +2,7 @@ package ru.daemonlord.messenger.data.realtime
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
@@ -70,6 +71,33 @@ class RealtimeEventParserTest {
assertEquals(5L, mapped.senderId) assertEquals(5L, mapped.senderId)
assertEquals(100L, mapped.replyToMessageId) assertEquals(100L, mapped.replyToMessageId)
assertEquals("hi", mapped.text) assertEquals("hi", mapped.text)
assertFalse(mapped.isMention)
}
@Test
fun parseReceiveMessage_withMentionFlag_mapsMention() {
val payload = """
{
"event": "receive_message",
"payload": {
"chat_id": 88,
"message": {
"id": 9002,
"chat_id": 88,
"sender_id": 5,
"type": "text",
"text": "@you hi",
"created_at": "2026-03-08T12:00:00Z",
"is_mention": true
}
}
}
""".trimIndent()
val event = parser.parse(payload)
assertTrue(event is RealtimeEvent.ReceiveMessage)
val mapped = event as RealtimeEvent.ReceiveMessage
assertTrue(mapped.isMention)
} }
@Test @Test

View File

@@ -92,7 +92,7 @@
- [x] Локальные уведомления для foreground - [x] Локальные уведомления для foreground
- [x] Notification channels (Android) - [x] Notification channels (Android)
- [x] Deep links: open chat/message - [x] Deep links: open chat/message
- [ ] Mention override для muted чатов - [x] Mention override для muted чатов
## 13. UI/UX и темы ## 13. UI/UX и темы
- [ ] Светлая/темная тема (читаемая) - [ ] Светлая/темная тема (читаемая)