android: add mention override for muted chat notifications
Some checks failed
CI / test (push) Failing after 2m18s
Some checks failed
CI / test (push) Failing after 2m18s
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 и темы
|
||||||
- [ ] Светлая/темная тема (читаемая)
|
- [ ] Светлая/темная тема (читаемая)
|
||||||
|
|||||||
Reference in New Issue
Block a user